From 88ab83e8bcf0f34bed9ecad5498689be3c99ced2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 06:07:08 -0700 Subject: [PATCH 01/86] fix(web): reconnecting notice no longer shifts the new thread composer (#26958) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 10 ++++- .../src/components/chat/ComposerBanner.tsx | 20 +++++++--- .../components/chat/ComposerBannerStack.tsx | 37 ++++++++++++++----- .../src/components/chat/ComposerSurface.tsx | 2 +- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index de76f47844bfa..665bb2fc5f5cf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -306,7 +306,7 @@ import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullReq import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; -import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; @@ -3516,7 +3516,7 @@ export default function ChatView(props: ChatViewProps) { : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} - bannerItems={composerBannerItems} + bannerItems={isDraftHeroState ? [] : composerBannerItems} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -3682,6 +3682,12 @@ export default function ChatView(props: ChatViewProps) { )} + {isDraftHeroState && composerBannerItems.length > 0 ? ( + // Out of flow, like the headline, so a notice never moves the centered composer. +
+ +
+ ) : null}
= { "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]", }; +/** + * An attached surface tucks its bottom under the card below it. A detached one is a whole card + * of its own, for a notice that sits apart from the composer. + */ function Surface({ variant = "default", + detached = false, className, ...props }: ComponentProps<"div"> & { variant?: ComposerBannerVariant; + detached?: boolean; }) { return (
& { +}: ComponentProps & { density?: "default" | "comfortable" | "spacious"; - variant?: ComposerBannerVariant; width?: "fill" | "content"; }) { return ( diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 086826cde86db..755d20ba8c6ae 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -7,7 +7,7 @@ import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; import { observeResize } from "~/lib/observeResize"; /** - * Where a notice sits above the composer. The machine comes first, then what needs you; the + * Where a notice sits in the stack. The machine comes first, then what needs you; the * order is fixed so rows never trade places as notices come and go. */ export type ComposerBannerGroup = "environment" | "attention"; @@ -27,23 +27,33 @@ export interface ComposerBannerStackItem { const GROUPS = ["environment", "attention"] as const; +const FADE_IN = + "transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"; + /** * Notices about sending from this composer, each its own attached card. Consecutive cards join - * edge to edge, and the last joins the composer or the drawer below it. + * edge to edge, and the last joins the composer or the drawer below it. Detached cards stand + * apart instead, for layouts where the composer must not move when a notice appears. */ export function ComposerBannerStack({ items, + detached = false, }: { readonly items: ReadonlyArray; + readonly detached?: boolean; }) { - return GROUPS.flatMap((group) => + const ordered = GROUPS.flatMap((group) => items.filter((item) => (item.group ?? "attention") === group), - ).map((item) => ( - + ); + if (detached) { + return ordered.map((item) => ( +
+ +
+ )); + } + return ordered.map((item) => ( + )); @@ -125,11 +135,18 @@ export function NoticeText({ ); } -function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) { +function NoticeCard({ + item, + detached = false, +}: { + readonly item: ComposerBannerStackItem; + readonly detached?: boolean; +}) { return ( diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx index 13614b8c9bd4c..213189064ee11 100644 --- a/apps/web/src/components/chat/ComposerSurface.tsx +++ b/apps/web/src/components/chat/ComposerSurface.tsx @@ -41,7 +41,7 @@ function Host({ className, ...props }: ComponentProps<"div">) { "relative z-10 w-full rounded-2xl shadow-composer after:z-1 dark:shadow-none", "group-data-context-row/composer-surface:before:pointer-events-none group-data-context-row/composer-surface:before:absolute group-data-context-row/composer-surface:before:inset-0 group-data-context-row/composer-surface:before:-z-1 group-data-context-row/composer-surface:before:rounded-2xl group-data-context-row/composer-surface:before:bg-(--chat-composer-glass-surface)/(--glass-opacity) group-data-context-row/composer-surface:before:backdrop-blur-(--glass-blur) group-data-context-row/composer-surface:before:backdrop-saturate-(--glass-saturation)", outlineClasses, - "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden", + "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:before:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden", className, )} {...props} From 20497e48907c2ddbbd9a2ab3c3ef74024f9e4a74 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 06:21:54 -0700 Subject: [PATCH 02/86] feat(marketing): homepage looks like the app (#26960) Co-authored-by: Claude Opus 5.5 --- apps/marketing/package.json | 1 + apps/marketing/public/fonts/dm-sans-OFL.txt | 93 --------- .../public/fonts/jetbrains-mono-OFL.txt | 93 --------- .../src/assets/fonts/dm-sans-latin-ext.woff2 | Bin 31292 -> 0 bytes .../src/assets/fonts/dm-sans-latin.woff2 | Bin 62724 -> 0 bytes .../fonts/jetbrains-mono-cyrillic-ext.woff2 | Bin 1640 -> 0 bytes .../fonts/jetbrains-mono-cyrillic.woff2 | Bin 8872 -> 0 bytes .../assets/fonts/jetbrains-mono-greek.woff2 | Bin 6836 -> 0 bytes .../fonts/jetbrains-mono-latin-ext.woff2 | Bin 11624 -> 0 bytes .../assets/fonts/jetbrains-mono-latin.woff2 | Bin 31432 -> 0 bytes .../fonts/jetbrains-mono-vietnamese.woff2 | Bin 5888 -> 0 bytes apps/marketing/src/layouts/Layout.astro | 66 ++---- apps/marketing/src/lib/site.ts | 2 +- apps/marketing/src/pages/index.astro | 155 +++++--------- apps/marketing/src/styles/fonts.css | 193 ------------------ pnpm-lock.yaml | 3 + 16 files changed, 80 insertions(+), 526 deletions(-) delete mode 100644 apps/marketing/public/fonts/dm-sans-OFL.txt delete mode 100644 apps/marketing/public/fonts/jetbrains-mono-OFL.txt delete mode 100644 apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 delete mode 100644 apps/marketing/src/assets/fonts/dm-sans-latin.woff2 delete mode 100644 apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 delete mode 100644 apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 delete mode 100644 apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 delete mode 100644 apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 delete mode 100644 apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 delete mode 100644 apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 delete mode 100644 apps/marketing/src/styles/fonts.css diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 36dc282e89b47..e37dd8fb7a894 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -10,6 +10,7 @@ "preview": "astro preview" }, "dependencies": { + "@fontsource-variable/inter": "^5.3.0", "@t3tools/shared": "workspace:*", "astro": "^7.3.5", "sharp": "0.35.4" diff --git a/apps/marketing/public/fonts/dm-sans-OFL.txt b/apps/marketing/public/fonts/dm-sans-OFL.txt deleted file mode 100644 index a0eedfbf0df0e..0000000000000 --- a/apps/marketing/public/fonts/dm-sans-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/marketing/public/fonts/jetbrains-mono-OFL.txt b/apps/marketing/public/fonts/jetbrains-mono-OFL.txt deleted file mode 100644 index 821a3dac22aff..0000000000000 --- a/apps/marketing/public/fonts/jetbrains-mono-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. - -This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 b/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 deleted file mode 100644 index 6e84cba1949cd6d3c1147a716ff5c069ba0a9ca7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31292 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 ~^9@9j26aoxl#g z@in~*0{0eD**ZFJ(`aHrk=TB*?}^C?jveDx_V z8pvsI@AdtlGjfVl#xH1})mKG9Sd#XK%kq0sG;6AtadKK8gS~gs&_!}C+%%A>Mv zHTfS1KOW;TjB!UzH0mh}$rma&+Kz@+Ei~2SSWOibhk=Bdt@%49Ti%PtV`%n8ldqNj z9?>!+iDwvIC2G>_mGkbtV!$+)u)D7-(>RdkEuWFFA=F^6h{WN4;&)F0s@W#vlr+0P4P&{N4>CoL> z$by_xVN{N(W_js+@)FjIB}qvc_-DA~?_$`u5WTF!PpDz!uDSa0Rz`n-dzj7gMVCc6z6n;9Pn7oz z7lJS3@FTUJZ|w^?jgQ;vk;c}p^uc`#!DYXI`D_A0;FWH2GWg$GHR5jIbFbOrC&KkR z_3kr{b@Y0Dczy3#{~}}CTAKE88P8vSse+=bal~bq3HwGS@U42Vo{m?xEZgJ#b!4+1 z4&!0hbqk?#VLN2vce^Q;r};eUCTuDP6DgQ}TRP@iMLKk>B5O^vbGR>U2y*N(H^|{U zZHN84e68lE&ie;U=nWQ5yo5JULvV7Ud}sWWiRbuOnOw)JG@gWRFON6$eH!gszCN?6 zEF<`yo&$v)@`VEVI9;;zG-+HW9L|r1d{#8&gCV7IfWpt z&YsJ{$JjLyBEsP+2s%Ygp`S6~x8Al<_5hb0orH&w$nXe8kBwkWPsZo!>IYKh z<&Cy^fnc9d;BGFzPf*6)Tz;QmnJ!cc+zEs_RQ?|s{Uj2PQ}7PnfgRk!(|B4u6ezXR z#vN5`CU-?p;%+8)MS#KGOl}{cLpL#0%%|Q1XbO!&WzT*Ckj7mc7C<6EHTdn{eEs&6 z@y(d)XLoxd_#9hFg59)-5_2Zg~;DvOBkDCemQt?* zQWtoQQmtzN#kb%fy#36fzUT71RlS8Zn0HpmDDLQ>BjFB;i?}mG2xUr{c(MXQ7jvHQg%4hjI zRtd4WtrS7or!)<-K^f$xTbVSDNLgeiY*vu^&MhJ~@k*5!KkJo)%`eJF$Op=gL(w$> z_gxaaCZ1LiL|UpMVKY}nA=+pa4Tpg$1{S?l2sWK5ELxjb5{)TN)TVe*s1gtos}kYI z`Pn2mKyS++B&Jf9(o`%!X;#kU&ipx=9vyjbW1h+GJyr6~1EZCkWQ9FV)7O5to!W4L}EqnAzZRP?EIx=Gt}8shmh7 ziQ1Kp5(hC)_>(LAFP8WqJOgL5OtlAvE>g{haVJRxj~@?N@s zOX$Dyj(}`C<0o1So)3;(|J%XvWt z-|Lre&_i!2h>cC!`j9G3u$Y<@u}&sO6-w35Us3-(%w(nS2Q~+U;0#Dcl}xH?>Kfh4 zqN$~=lT}wwKZk*#QBLC?^ai<1&CuK!&gJn1d4(deq+%&yUWu$n@-T(6OKsmYV{2(; zZDVU^@8Ia)aXmi>qc};kyeO->X}f+Hw?()`A(Hbudu@N)#!tS4y{)RWRQ0cNr7D3C)MAJ+JX4mxPT9@8Lv*OjZm+7(V^}ITZC^x#LBXjoS z*@0!la__a8E#ZtSkCzSGD$gV!8DU}0!jcQP2(tSJ_<9_V;Ud)1J;-j&ium81@WuRI zFUzV_LV8J|whdmB#m!S^mksJQ-{P0{qZ!fA*ehvM-=ewmXme{b2sC%a&EHICqplsw zZnSNGxM=@@wYK~r+rOQ)*>U@)P;7iT(R(d_(%p^j)eeQb=Ni2mMS5Yn?@Cil8zFEl zp@H3efu{{zc!?|DOu}4^<(~8RAb3{_O2K^44}x&(3R8`+2XVADzLO16oIa$Hv?fCi z5fvqYJd$5Sp*YH*Ok0W79b{CNWJ%6TNGx?(USg^1$hvN@DR0_-JgKu?pwuqzqP}O< z=FVBn_F64^!;W<=>{i+CvwzdiG_%~QyYfA*`1HuoEH9t-Rzhhg$z3)JE@K!t+uCdI zC5e*St4k8CKPqVV(!APU)9I{wUS+zCxJwa=3=uhRx7U7VY=L(@%<#_o$^Z9(a*F`~ Df1}!W diff --git a/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 b/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 deleted file mode 100644 index c75f7bf1b4656e9c05822b6302fe6dc0f8fe0303..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 62724 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 diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 deleted file mode 100644 index 289de93149abef2225658543048409ac53b344fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1640 zcmV-u2ABDFPew8T0RR9100w9P6951J01V^+00spB0RR9100000000000000000000 z0000Q92*`SCkoM^-nRWgwfAza*p?1mEMoI?FyNLn_TR#0?uLQ%lR_BCm^P)wm4}1+QGD5wccRbp}f8S`ImB`P}jB+MWmk zxB&n`Fc1U*1Oc2RlZ^E0vwaLvbN~wg0yxPf7vAD&x*I|IVLX86$ zi1wB#5D5SZ985WtjVCZ*0eFw-`?U+*KS8@7#p%es|NaV;4*{HR1 zInxJx0rLiYK7@IW&Fhei8x28;9{>R~K#4BbfqDy20Akb9K^=jXZedI>y&7i~tBQu4 z0{K9WKqb`66!|YB00_o=JODpYRpsO?55PPC1!zDt8UjS4GYt@l9}R_vbb@|)UzGl= zzTdJyM?ItO<4;Wvc+WyBXzkic);F;O1~yz@06z-Zk#)YA`8ojNJC{cg9W41SG2o^) zx0vsgk9fr9Z$-R$BMUwNs7S?)ZvuV<*&q)z)NzG8MpH!yuf^>zgO8f0IV_MVlSyF6q@`!z<3xeho~`3ftP0HZXljx)z%d7= z5I=SB+c{|Rl$wE?QPrftPoRn!G-@NQo7GR{1=@PHoj#S-MroQFt4q@T;!7XoOj;{B ze_Jx3V##ZBS4meimzVJLES!Jk^d+A}ewfp5?B2p!-KxNs*EdMK3;ekVVB$#?L0)Vo zI=sjMH2{^Ma9-r@@=lLK22zIj4 z!N2#fjD$b;#bu}df5uX}HCL$J)!9jX|9tfN`>%iePly59I6HR|ka=})N5`S!@L81M zaQ+;fni|^1$?fK_e#OX)e#2fk+0G4XZ-?HVZE43krkG>+Q)A=DQu#$NLD3csktLIgozC!{pQagm!l9dTuyuC+ zo2sycOzFPZnUR(j3OVarTAS)+ep736-K7%j6||23Gm=6iNqCkv6H!W2YMXIpUv$^u z1oQlPT=8^+0IF8+^Q8Pe)z^I5u4(%KfBx{u9RPp+@!bisEUHF(1u#(n2z;8=YMf6{ zcl8_y6ZP8ZrRqt5e;F=&P7wgW=K|m-4gq{*qmhP|o>e+JiUW;BcK~mxbOMEBrK7?t zXHcVA-awsVxdsgqWfhtf%78>KwGy34z%6#v@yZ!AaB~4o?3_Xyk#wMoYMwzim25%} zr3^tY^~9nNH_pH5#|eypWDgV@Ob;ZyCz~kKBHaU?!mtA=kX61*Z!XhwBP>FaXLeU| zEQ8TvA$S&<2O}t3P>CEzcUCcKogU80^H>}c@QTzb!~VV?ms8_yt{2K-p^ur>{d4ulJ)l*VE^X%nkc5+ zT7v+>J^O%cJ{Lu^XrhZDrr6NLE)H>u%M8xYsZArRm+*t?U~d%CyPi+__bK0C%$+XF m4~5s|!E)7eM0)y6%cMWdBKUI_CTX`-mDe_9|HbM#{b~TxIQ=#N diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 deleted file mode 100644 index de39acbc40690eba8a6e4beb11ed8aa26656b07f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8872 zcmV;ZB3IpaPew8T0RR9103xUW6951J07iHK03tmA0RR9100000000000000000000 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 diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 deleted file mode 100644 index e38f5538f84e920b822eeb9a3dd3ea1e20bc1797..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5888 zcmV+b7ysyYPew8T0RR9102crN6951J06*ja02Y`40RR9100000000000000000000 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 WADn8I>W@j`6r5^GWB*o5H{S_n@f*(o diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 6b4bf819e2d12..d1aea12f09bc9 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -2,8 +2,7 @@ import { getImage, Image } from "astro:assets"; import appIcon from "../assets/icon.webp"; import socialCard from "../assets/social-card.webp"; -import dmSansLatinUrl from "../assets/fonts/dm-sans-latin.woff2?url"; -import "../styles/fonts.css"; +import "@fontsource-variable/inter"; import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, @@ -58,13 +57,6 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); - @@ -153,22 +145,21 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); diff --git a/apps/marketing/src/styles/fonts.css b/apps/marketing/src/styles/fonts.css deleted file mode 100644 index 2f3933d61239c..0000000000000 --- a/apps/marketing/src/styles/fonts.css +++ /dev/null @@ -1,193 +0,0 @@ -/*! DM Sans v17 and JetBrains Mono v24. Licenses: /fonts/dm-sans-OFL.txt and /fonts/jetbrains-mono-OFL.txt. */ -/* Keep subset bytes out of the stylesheet so unused glyph ranges do not add transfer size. */ -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* cyrillic-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic-ext.woff2?no-inline) format("woff2"); - unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; -} -/* cyrillic */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic.woff2?no-inline) format("woff2"); - unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} -/* greek */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-greek.woff2?no-inline) format("woff2"); - unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; -} -/* vietnamese */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-vietnamese.woff2?no-inline) format("woff2"); - unicode-range: - U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, - U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; -} -/* latin-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* cyrillic-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic-ext.woff2?no-inline) format("woff2"); - unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; -} -/* cyrillic */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic.woff2?no-inline) format("woff2"); - unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} -/* greek */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-greek.woff2?no-inline) format("woff2"); - unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; -} -/* vietnamese */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-vietnamese.woff2?no-inline) format("woff2"); - unicode-range: - U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, - U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; -} -/* latin-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 087858aeabb82..4bff27f291a17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -499,6 +499,9 @@ importers: apps/marketing: dependencies: + '@fontsource-variable/inter': + specifier: ^5.3.0 + version: 5.3.0 '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared From 37fbefd147197fab9d88f9c120560c3f21b52cfc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 06:52:00 -0700 Subject: [PATCH 03/86] chore(release): prepare v0.1.5 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7f768c1b44d75..886ccf3cd798f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index bd81fd993c43e..9ee890670abbf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index c35aadeef5d3f..2ee8fe51a06a3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index cff21c52a6e32..2a032000896fb 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.4", + "version": "0.1.5", "private": true, "files": [ "dist" From 960d5761c13f68a2f10635bcd26489a5fa404ec1 Mon Sep 17 00:00:00 2001 From: "t3-code-bot[bot]" <269035359+t3-code-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:01:14 +0000 Subject: [PATCH 04/86] fix(cli): restore Windows desktop launcher console input (#24870) Co-authored-by: Nathan Shan Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/server/src/cli/consoleStdin.test.ts | 127 +++++++++++++++++++++++ apps/server/src/cli/consoleStdin.ts | 31 ++++-- 2 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/cli/consoleStdin.test.ts diff --git a/apps/server/src/cli/consoleStdin.test.ts b/apps/server/src/cli/consoleStdin.test.ts new file mode 100644 index 0000000000000..c799d3c2a5e10 --- /dev/null +++ b/apps/server/src/cli/consoleStdin.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const io = vi.hoisted(() => ({ + platform: vi.fn(() => "win32"), + stat: vi.fn(() => ({ isCharacterDevice: (): boolean => true })), + open: vi.fn(() => 42), + close: vi.fn(), + isatty: vi.fn((fd: number) => fd !== 0), +})); + +vi.mock("node:fs", () => ({ fstatSync: io.stat, openSync: io.open, closeSync: io.close })); +vi.mock("node:tty", async () => { + const { EventEmitter } = await import("node:events"); + return { + isatty: io.isatty, + ReadStream: class extends EventEmitter { + readonly isTTY = true; + readonly _readableState = { reading: true }; + _handle: { reading: boolean; readStop: () => void } | undefined = { + reading: true, + readStop: vi.fn(), + }; + }, + }; +}); +vi.mock("@t3tools/shared/HostProcess", () => ({ Platform: { defaultValue: io.platform } })); + +const originalStdin = Object.getOwnPropertyDescriptor(process, "stdin")!; + +beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.stubEnv("T3CODE_DESKTOP_CLI_LAUNCHER", "1"); +}); + +afterEach(() => { + Object.defineProperty(process, "stdin", originalStdin); + vi.unstubAllEnvs(); +}); + +describe("attachConsoleStdin", () => { + it("reopens the console when Node replaces missing Windows stdin with NUL", async () => { + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + + expect(attachConsoleStdin()).toBe(42); + expect(process.stdin.isTTY).toBe(true); + expect(interactiveStdin()).toBe(42); + expect(process.env.T3CODE_DESKTOP_CLI_LAUNCHER).toBeUndefined(); + }); + + it("releases console reads when the picker pauses stdin before launching an agent", async () => { + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + attachConsoleStdin(); + const stdin = process.stdin as unknown as { + emit(event: string): boolean; + _readableState: { reading: boolean }; + _handle: { reading: boolean; readStop: () => void }; + }; + stdin.emit("pause"); + expect(stdin._readableState.reading).toBe(false); + expect(stdin._handle.reading).toBe(false); + expect(stdin._handle.readStop).toHaveBeenCalledOnce(); + expect(interactiveStdin()).toBe(42); + }); + + it("can pause after the reopened stream's handle has been closed", async () => { + const { attachConsoleStdin } = await import("./consoleStdin.ts"); + attachConsoleStdin(); + const stdin = process.stdin as unknown as { + emit(event: string): boolean; + _handle: undefined; + }; + stdin._handle = undefined; + expect(() => stdin.emit("pause")).not.toThrow(); + }); + + it("still reopens a closed stdin", async () => { + io.stat.mockImplementation(() => { + throw new Error("EBADF"); + }); + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBe(42); + expect(interactiveStdin()).toBe(42); + }); + + it.each(["pipe", "file"])("preserves redirected %s input", async () => { + io.stat.mockReturnValue({ isCharacterDevice: () => false }); + const stdin = process.stdin; + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBeUndefined(); + expect(process.stdin).toBe(stdin); + expect(interactiveStdin()).toBe("inherit"); + expect(io.open).not.toHaveBeenCalled(); + }); + + it.each(["console input", "redirected output", "unmarked process", "non-Windows"])( + "leaves %s alone", + async (scenario) => { + if (scenario === "console input") io.isatty.mockReturnValue(true); + if (scenario === "redirected output") io.isatty.mockReturnValue(false); + if (scenario === "unmarked process") delete process.env.T3CODE_DESKTOP_CLI_LAUNCHER; + if (scenario === "non-Windows") io.platform.mockReturnValue("darwin"); + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBeUndefined(); + expect(interactiveStdin()).toBe("inherit"); + expect(io.open).not.toHaveBeenCalled(); + expect(process.env.T3CODE_DESKTOP_CLI_LAUNCHER).toBeUndefined(); + }, + ); + + it("keeps inherited input when CONIN$ cannot be opened", async () => { + io.open.mockImplementation(() => { + throw new Error("ENOENT"); + }); + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBeUndefined(); + expect(interactiveStdin()).toBe("inherit"); + }); + + it("closes a reopened descriptor that is not a console", async () => { + io.isatty.mockImplementation((fd) => fd === 1); + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBeUndefined(); + expect(interactiveStdin()).toBe("inherit"); + expect(io.close).toHaveBeenCalledWith(42); + }); +}); diff --git a/apps/server/src/cli/consoleStdin.ts b/apps/server/src/cli/consoleStdin.ts index 91232cb310871..18ee7bed6d5a1 100644 --- a/apps/server/src/cli/consoleStdin.ts +++ b/apps/server/src/cli/consoleStdin.ts @@ -5,31 +5,30 @@ import * as NodeTty from "node:tty"; import * as HostProcess from "@t3tools/shared/HostProcess"; /** - * Electron's Windows executable is a GUI program, so `t3.cmd` under - * `ELECTRON_RUN_AS_NODE` gets the console for stdout and stderr but no stdin. - * Piped or redirected input and runs whose output is not a console are left alone. + * Electron's Windows launcher has no stdin; Node substitutes a non-console NUL device. + * Preserve pipes, files, console input, and runs without console output. + * Explicit `< NUL` is indistinguishable from missing launcher stdin and also reattaches. */ function needsConsoleStdin(input: { readonly platform: NodeJS.Platform; readonly env: NodeJS.ProcessEnv; - readonly stdinOpen: boolean; + readonly stdinMissing: boolean; readonly stdoutIsConsole: boolean; }): boolean { return ( input.platform === "win32" && // Set only by the launcher (DesktopCliShim), never for the desktop backend. input.env.T3CODE_DESKTOP_CLI_LAUNCHER === "1" && - !input.stdinOpen && + input.stdinMissing && input.stdoutIsConsole ); } -const isOpen = (fd: number) => { +const isMissingInput = (fd: number) => { try { - NodeFS.fstatSync(fd); - return true; + return NodeFS.fstatSync(fd).isCharacterDevice() && !NodeTty.isatty(fd); } catch { - return false; + return true; } }; @@ -38,7 +37,7 @@ export function attachConsoleStdin(): number | undefined { const needed = needsConsoleStdin({ platform: HostProcess.Platform.defaultValue(), env: process.env, - stdinOpen: isOpen(0), + stdinMissing: isMissingInput(0), stdoutIsConsole: NodeTty.isatty(1), }); // The launcher marker must not propagate to child shells or t3 processes. @@ -55,6 +54,18 @@ export function attachConsoleStdin(): number | undefined { return undefined; } const stdin = new NodeTty.ReadStream(fd); + // Like Node's built-in stdin, release pending console reads before a child takes over. + stdin.on("pause", () => { + const internal = stdin as unknown as { + _handle?: { reading: boolean; readStop(): void }; + _readableState: { reading: boolean }; + }; + const handle = internal._handle; + if (!handle) return; + internal._readableState.reading = false; + handle.reading = false; + handle.readStop(); + }); Object.defineProperty(process, "stdin", { value: stdin, configurable: true, enumerable: true }); consoleStdinFd = fd; return fd; From 7a4f5b6188fed54ddc866723e39446a198b5835c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 29 Oct 2026 13:15:27 -0400 Subject: [PATCH 05/86] feat(settings): add and list 1Password secret providers behind T3CODE_FEATURE_SECRETS (#25709) Signed-off-by: Yordis Prieto Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 8 +- apps/web/src/components/Icons.tsx | 9 + .../SecretProviderDialog.logic.test.ts | 51 ++++ .../settings/SecretProviderDialog.logic.ts | 117 ++++++++++ .../settings/SecretProviderDialog.tsx | 155 ++++++++++++ .../components/settings/SecretsSettings.tsx | 220 ++++++++++++++++++ .../settings/SettingsSidebarNav.tsx | 2 + .../settings/settingsSearch.test.ts | 11 + .../src/components/settings/settingsSearch.ts | 31 ++- apps/web/src/env.ts | 4 + apps/web/src/routeTree.gen.ts | 21 ++ .../web/src/routes/_chat.settings.secrets.tsx | 11 + apps/web/src/routes/_chat.settings.tsx | 2 +- apps/web/src/vite-env.d.ts | 1 + apps/web/vite.config.ts | 2 + packages/contracts/src/settings.test.ts | 50 ++++ packages/contracts/src/settings.ts | 63 ++++- packages/shared/src/serverSettings.test.ts | 25 ++ packages/shared/src/serverSettings.ts | 4 + 19 files changed, 782 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/settings/SecretProviderDialog.logic.test.ts create mode 100644 apps/web/src/components/settings/SecretProviderDialog.logic.ts create mode 100644 apps/web/src/components/settings/SecretProviderDialog.tsx create mode 100644 apps/web/src/components/settings/SecretsSettings.tsx create mode 100644 apps/web/src/routes/_chat.settings.secrets.tsx diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 0a9f97cc39140..24c22280175cc 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -6,6 +6,7 @@ import { AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, ProviderInstanceId, + SecretProviderId, ThreadId, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -173,7 +174,7 @@ describe("settings mutation authorization", () => { }).pipe(Effect.scoped), ); - it.effect("does not let a settings grant create or remove providers", () => + it.effect("does not let a settings grant create or remove providers or secret providers", () => Effect.gen(function* () { let handled = false; const client = yield* RpcTest.makeClient(group).pipe( @@ -195,6 +196,11 @@ describe("settings mutation authorization", () => { providerInstanceMutation, }).pipe(Effect.flip), ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); + expect( + yield* client[WS_METHODS.serverUpdateSettings]({ + patch: { secretProviders: { [SecretProviderId.make("personal")]: null } }, + }).pipe(Effect.flip), + ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); expect(handled).toBe(false); }).pipe(Effect.scoped), ); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 551312e23f1dc..fd1a9f461d7d3 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -505,3 +505,12 @@ export const MacAccessibilityIcon: Icon = (props) => { ); }; + +export const OnePasswordIcon: Icon = (props) => ( + + + +); diff --git a/apps/web/src/components/settings/SecretProviderDialog.logic.test.ts b/apps/web/src/components/settings/SecretProviderDialog.logic.test.ts new file mode 100644 index 0000000000000..a23ea149d480f --- /dev/null +++ b/apps/web/src/components/settings/SecretProviderDialog.logic.test.ts @@ -0,0 +1,51 @@ +import { SecretProviderId } from "@t3tools/contracts"; +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { secretProviderFromDraft } from "./SecretProviderDialog.logic"; + +describe("secretProviderFromDraft", () => { + it("adds a provider keyed by its name and reports every field that blocks it", () => { + expect( + secretProviderFromDraft({ name: " 1Password! ", account: "my.1password.com" }, new Set(), { + mode: "add", + }), + ).toEqual( + Result.succeed({ + id: "1password", + config: { + kind: "one_password", + label: "1Password!", + config: { account: "my.1password.com" }, + }, + }), + ); + + const blocked = secretProviderFromDraft( + { name: "1Password", account: "--help" }, + new Set(["1password"]), + { mode: "add" }, + ); + expect(Result.isFailure(blocked) && Object.keys(blocked.failure).toSorted()).toEqual([ + "account", + "name", + ]); + }); + + it("keeps the id when an edit renames the provider", () => { + const id = SecretProviderId.make("personal"); + const result = secretProviderFromDraft( + { name: "Personal (team)", account: "team.1password.com" }, + new Set(["personal"]), + { mode: "edit", id }, + ); + expect(Result.isSuccess(result) && result.success).toEqual({ + id, + config: { + kind: "one_password", + label: "Personal (team)", + config: { account: "team.1password.com" }, + }, + }); + }); +}); diff --git a/apps/web/src/components/settings/SecretProviderDialog.logic.ts b/apps/web/src/components/settings/SecretProviderDialog.logic.ts new file mode 100644 index 0000000000000..11139d9c7aa67 --- /dev/null +++ b/apps/web/src/components/settings/SecretProviderDialog.logic.ts @@ -0,0 +1,117 @@ +import { + OnePasswordSecretProviderConfig, + SecretProviderId, + SecretProviderKind, + SecretProviderLabel, + type OnePasswordAccount, + type SecretProviderConfig, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +const decodeSecretProviderId = Schema.decodeOption(SecretProviderId); +const decodeOnePasswordConfig = Schema.decodeUnknownOption(OnePasswordSecretProviderConfig); + +export type ClassifiedSecretProvider = + | { readonly kind: "one_password"; readonly account: OnePasswordAccount } + | { readonly kind: "unsupported" }; + +/** Unknown kinds and configs that no longer decode are shown read-only; removal stays available. */ +export function classifySecretProvider(provider: SecretProviderConfig): ClassifiedSecretProvider { + if (provider.kind !== "one_password") return { kind: "unsupported" }; + const decoded = decodeOnePasswordConfig(provider.config); + return decoded._tag === "Some" + ? { kind: "one_password", account: decoded.value.account } + : { kind: "unsupported" }; +} + +/** Stable and readable in settings.json, so re-adding a provider under the same name restores its variables. */ +function secretProviderIdFromName(name: string): SecretProviderId | null { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 64) + .replace(/-$/, ""); + return Option.getOrNull(decodeSecretProviderId(slug)); +} + +export interface SecretProviderDraft { + readonly name: string; + readonly account: string; +} + +export function draftFromSecretProvider(provider: SecretProviderConfig): SecretProviderDraft { + const classified = classifySecretProvider(provider); + return { + name: provider.label, + account: classified.kind === "one_password" ? classified.account : "", + }; +} + +export type SecretProviderTarget = + | { readonly mode: "add" } + | { readonly mode: "edit"; readonly id: SecretProviderId }; + +export type SecretProviderDraftIssues = { + readonly [Field in keyof SecretProviderDraft]?: string; +}; + +function decodeField>( + schema: S, + value: string, + emptyMessage: string, +): Result.Result { + const trimmed = value.trim(); + if (trimmed.length === 0) return Result.fail(emptyMessage); + return Result.mapError(Schema.decodeResult(schema)(trimmed), (error) => error.message); +} + +/** The settings entry a draft adds or replaces, or every field that blocks it, one message each. */ +export function secretProviderFromDraft( + draft: SecretProviderDraft, + existingIds: ReadonlySet, + target: SecretProviderTarget, +): Result.Result< + { readonly id: SecretProviderId; readonly config: SecretProviderConfig }, + SecretProviderDraftIssues +> { + const label = decodeField(SecretProviderLabel, draft.name, "Enter a name."); + const account = decodeField( + OnePasswordSecretProviderConfig.fields.account, + draft.account, + "Enter the 1Password account.", + ); + + let id: SecretProviderId | null = null; + let nameIssue: string | undefined; + if (Result.isFailure(label)) { + nameIssue = label.failure; + } else { + id = target.mode === "edit" ? target.id : secretProviderIdFromName(label.success); + if (id === null) nameIssue = "The name needs at least one letter or digit."; + else if (target.mode === "add" && existingIds.has(id)) + nameIssue = "A secret provider with this name exists."; + } + + if ( + id === null || + nameIssue !== undefined || + Result.isFailure(label) || + Result.isFailure(account) + ) { + return Result.fail({ + ...(nameIssue === undefined ? {} : { name: nameIssue }), + ...(Result.isFailure(account) ? { account: account.failure } : {}), + }); + } + return Result.succeed({ + id, + config: { + kind: SecretProviderKind.make("one_password"), + label: label.success, + config: { account: account.success }, + }, + }); +} diff --git a/apps/web/src/components/settings/SecretProviderDialog.tsx b/apps/web/src/components/settings/SecretProviderDialog.tsx new file mode 100644 index 0000000000000..9370fcc68b3ee --- /dev/null +++ b/apps/web/src/components/settings/SecretProviderDialog.tsx @@ -0,0 +1,155 @@ +import type { EnvironmentId, SecretProviderConfig, SecretProviderId } from "@t3tools/contracts"; +import * as Result from "effect/Result"; +import { useState } from "react"; + +import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { + draftFromSecretProvider, + secretProviderFromDraft, + type SecretProviderDraft, +} from "./SecretProviderDialog.logic"; + +/** Mounted per open, so each open starts from a fresh draft. */ +export function SecretProviderDialog({ + onClose, + environmentId, + environmentLabel, + existingIds, + existing, +}: { + readonly onClose: () => void; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly existingIds: ReadonlySet; + readonly existing?: { readonly id: SecretProviderId; readonly config: SecretProviderConfig }; +}) { + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [draft, setDraft] = useState( + existing ? draftFromSecretProvider(existing.config) : { name: "", account: "" }, + ); + const [submitted, setSubmitted] = useState(false); + const result = secretProviderFromDraft( + draft, + existingIds, + existing ? { mode: "edit", id: existing.id } : { mode: "add" }, + ); + const issues = Result.isFailure(result) ? result.failure : {}; + const nameError = submitted ? issues.name : undefined; + const accountError = submitted ? issues.account : undefined; + + const update = (patch: Partial) => setDraft({ ...draft, ...patch }); + const save = () => { + setSubmitted(true); + if (Result.isFailure(result)) return; + updateSettings({ secretProviders: { [result.success.id]: result.success.config } }); + onClose(); + }; + + return ( +

{ + if (!next) onClose(); + }} + > + + + + {existing ? `Edit ${existing.config.label}` : "Add a 1Password provider"} + + + Provider variables on {environmentLabel} can read values from it by name. Values are + read with the op CLI on that server when a provider starts. + + + +
{ + event.preventDefault(); + save(); + }} + > +
+ + update({ name: event.target.value })} + maxLength={64} + aria-invalid={nameError !== undefined} + aria-describedby={ + nameError !== undefined ? "secret-provider-name-error" : undefined + } + autoFocus + /> + {nameError !== undefined ? ( + + ) : null} +
+
+ + update({ account: event.target.value })} + maxLength={253} + spellCheck={false} + aria-invalid={accountError !== undefined} + aria-describedby={ + accountError !== undefined ? "secret-provider-account-error" : undefined + } + /> + {accountError !== undefined ? ( + + ) : null} +

+ The account shorthand, sign-in address without https://, or ID that op account list + shows. +

+
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/SecretsSettings.tsx b/apps/web/src/components/settings/SecretsSettings.tsx new file mode 100644 index 0000000000000..e9efe2d85f6ed --- /dev/null +++ b/apps/web/src/components/settings/SecretsSettings.tsx @@ -0,0 +1,220 @@ +import type { SecretProviderConfig, SecretProviderId } from "@t3tools/contracts"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; + +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import type { EnvironmentPresentation } from "../../state/environments"; +import { useEnvironmentSessionState } from "../../state/session"; +import { OnePasswordIcon, type Icon } from "../Icons"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { SecretProviderDialog } from "./SecretProviderDialog"; +import { classifySecretProvider } from "./SecretProviderDialog.logic"; +import { + classifyProviderEnvironmentAccess, + resolveRemoteOperateAccess, +} from "./ProviderSettingsPanel.logic"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { searchableSetting } from "./settingsSearch"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const SECRET_PROVIDER_KINDS: Readonly< + Record +> = { + one_password: { name: "1Password", icon: OnePasswordIcon }, +}; + +export function SecretsSettingsPanel() { + const { scope, environment } = useSettingsScope(); + return ( + + {environment?.connection.phase === "connected" ? ( + + ) : ( + +

+ Connect an environment to manage the secret providers its provider variables read from. +

+
+ )} +
+ ); +} + +function EnvironmentSecretProviders({ + environment, + projectScoped, +}: { + readonly environment: EnvironmentPresentation; + readonly projectScoped: boolean; +}) { + const environmentId = environment.environmentId; + const sessionState = useEnvironmentSessionState(environmentId); + // The server requires the same grant for secret providers as for provider settings. + const access = classifyProviderEnvironmentAccess({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + operateAccess: resolveRemoteOperateAccess({ + session: sessionState.data, + isPending: sessionState.isPending, + hasError: sessionState.hasError, + }), + }); + const readOnly = access.kind !== "editable" || projectScoped; + const providers = useEnvironmentSettings(environmentId, (settings) => settings.secretProviders); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [dialog, setDialog] = useState< + null | { readonly mode: "add" } | { readonly mode: "edit"; readonly id: SecretProviderId } + >(null); + const entries = Object.entries(providers) as Array<[SecretProviderId, SecretProviderConfig]>; + const editingConfig = dialog?.mode === "edit" ? providers[dialog.id] : undefined; + const editing = + dialog?.mode === "edit" && editingConfig !== undefined + ? { id: dialog.id, config: editingConfig } + : undefined; + return ( + <> + setDialog({ mode: "add" })}> + + Add provider + + ) : null + } + > + {projectScoped ? ( +

+ Environment-wide setting. Select an environment to change it. +

+ ) : null} + {entries.length === 0 ? ( + + ) : ( + entries.map(([id, config]) => { + const classified = classifySecretProvider(config); + const kind = + classified.kind === "unsupported" + ? undefined + : SECRET_PROVIDER_KINDS[classified.kind]; + return ( + + {kind ? ( + + ) : null} + {config.label} + + } + description={ + classified.kind === "one_password" && kind ? ( + + {kind.name} · + + + ) : ( + `Unsupported provider (${config.kind})` + ) + } + control={ + !readOnly ? ( +
+ {kind ? ( + + ) : null} + updateSettings({ secretProviders: { [id]: null } })} + /> +
+ ) : null + } + /> + ); + }) + )} +
+ {dialog !== null && !readOnly && (dialog.mode === "add" || editing !== undefined) ? ( + setDialog(null)} + environmentId={environmentId} + environmentLabel={environment.label} + existingIds={new Set(Object.keys(providers))} + {...(editing === undefined ? {} : { existing: editing })} + /> + ) : null} + + ); +} + +function RemoveSecretProviderButton({ + label, + onConfirm, +}: { + readonly label: string; + readonly onConfirm: () => void; +}) { + const [open, setOpen] = useState(false); + return ( + <> + + + + + Remove {label}? + + Provider variables that read from {label} stop resolving until a provider with the + same name is added again. The secrets themselves are untouched. + + + + }>Cancel + + + + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index b58f56a162324..ee061be1ebf78 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -22,6 +22,7 @@ import { MessagesSquareIcon, PanelsTopLeftIcon, KeyboardIcon, + KeyRoundIcon, PaletteIcon, SearchIcon, ServerIcon, @@ -102,6 +103,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/worktrees": FolderTreeIcon, "/settings/connections": ServerIcon, "/settings/access": ShieldCheckIcon, + "/settings/secrets": KeyRoundIcon, "/settings/maintenance": WrenchIcon, "/settings/labs": FlaskConicalIcon, "/settings/about": InfoIcon, diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 3013298c6ecb6..07d1d5dd6c8b9 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -14,12 +14,18 @@ import { isSettingsSearchScopeAvailable, movedSettingsPath, searchSettings, + SETTINGS_NAV_GROUPS, SETTINGS_SEARCH_ITEMS, sourceControlHostSearchItems, sourceControlHostSettingsSearchId, type SettingsSearchItem, } from "./settingsSearch"; +vi.mock("~/env", async (importOriginal) => ({ + ...(await importOriginal()), + isSecretsFeatureEnabled: false, +})); + const ITEMS: ReadonlyArray = [ { id: "word-wrap", @@ -60,6 +66,11 @@ describe("searchSettings", () => { expect(searchSettings("long lines", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); }); + it("hides the Secrets page from nav and search while the secrets feature is disabled", () => { + expect(SETTINGS_NAV_GROUPS.flatMap((group) => group.paths)).not.toContain("/settings/secrets"); + expect(searchSettings("1password").map((item) => item.to)).not.toContain("/settings/secrets"); + }); + it("matches normalized title substrings", () => { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1d5c13e72a061..dc494caf5344d 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,5 +1,5 @@ import { HOSTED_APP_CHANNEL } from "~/branding"; -import { isElectron } from "~/env"; +import { isElectron, isSecretsFeatureEnabled } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -33,6 +33,7 @@ export type SettingsPath = | "/settings/worktrees" | "/settings/connections" | "/settings/access" + | "/settings/secrets" | "/settings/maintenance" | "/settings/labs" | "/settings/about" @@ -57,6 +58,7 @@ export interface SettingsSearchItem { readonly macOnly?: boolean; readonly windowsOnly?: boolean; readonly releaseChannelOnly?: boolean; + readonly secretsFeatureOnly?: boolean; readonly cloudOnly?: boolean; readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; @@ -101,6 +103,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/worktrees": "Worktrees", "/settings/connections": "Environments", "/settings/access": "Sharing & access", + "/settings/secrets": "Secrets", "/settings/maintenance": "Maintenance", "/settings/labs": "Labs", "/settings/about": "About", @@ -137,7 +140,14 @@ export const SETTINGS_NAV_GROUPS: ReadonlyArray<{ heading: "Code", paths: ["/settings/projects", "/settings/source-control", "/settings/worktrees"], }, - { heading: "Connections", paths: ["/settings/connections", "/settings/access"] }, + { + heading: "Connections", + paths: [ + "/settings/connections", + "/settings/access", + ...(isSecretsFeatureEnabled ? (["/settings/secrets"] as const) : []), + ], + }, { heading: null, paths: ["/settings/maintenance", "/settings/labs", "/settings/about", "/settings/archived"], @@ -1142,6 +1152,21 @@ export const SETTINGS_SEARCH_ITEMS = [ `pull request routing trusted environments shared credentials permissions read actions source control ${SOURCE_CONTROL_HOST_LABELS}`, ], }, + { + id: "secret-providers", + title: "Secret providers", + to: "/settings/secrets", + searchTerms: ["1password vault credentials api key"], + secretsFeatureOnly: true, + }, + { + id: "secret-providers-add", + title: "Add secret provider", + to: "/settings/secrets", + targetId: "secret-providers", + searchTerms: ["new 1password vault op cli account"], + secretsFeatureOnly: true, + }, { id: "archive", title: "Archived threads", @@ -1171,6 +1196,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly (isElectron || item.desktopOnly !== true) && (!item.releaseChannelOnly || isElectron || HOSTED_APP_CHANNEL !== null) && + (!item.secretsFeatureOnly || isSecretsFeatureEnabled) && (!item.macOnly || isMacPlatform(platform)) && (!item.windowsOnly || isWindowsPlatform(platform)), ); diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index be2a563d736bd..a0a28c5d7fd60 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -1,2 +1,6 @@ // Preload installs desktopBridge before web code runs, so module-load detection is reliable. export const isElectron = typeof window !== "undefined" && window.desktopBridge !== undefined; + +export const isSecretsFeatureEnabled = ["1", "true"].includes( + import.meta.env.T3CODE_FEATURE_SECRETS?.trim().toLowerCase() ?? "", +); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index d92ea6110863e..0e26ab6edd879 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ChatSettingsThreadsRouteImport } from './routes/_chat.settings import { Route as ChatSettingsStorageRouteImport } from './routes/_chat.settings.storage' import { Route as ChatSettingsSourceControlRouteImport } from './routes/_chat.settings.source-control' import { Route as ChatSettingsSnapShotRouteImport } from './routes/_chat.settings.snap-shot' +import { Route as ChatSettingsSecretsRouteImport } from './routes/_chat.settings.secrets' import { Route as ChatSettingsScheduledTasksRouteImport } from './routes/_chat.settings.scheduled-tasks' import { Route as ChatSettingsProvidersRouteImport } from './routes/_chat.settings.providers' import { Route as ChatSettingsProjectsRouteImport } from './routes/_chat.settings.projects' @@ -134,6 +135,11 @@ const ChatSettingsSnapShotRoute = ChatSettingsSnapShotRouteImport.update({ path: '/snap-shot', getParentRoute: () => ChatSettingsRoute, } as any) +const ChatSettingsSecretsRoute = ChatSettingsSecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => ChatSettingsRoute, +} as any) const ChatSettingsScheduledTasksRoute = ChatSettingsScheduledTasksRouteImport.update({ id: '/scheduled-tasks', @@ -283,6 +289,7 @@ export interface FileRoutesByFullPath { '/settings/projects': typeof ChatSettingsProjectsRoute '/settings/providers': typeof ChatSettingsProvidersRoute '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/settings/secrets': typeof ChatSettingsSecretsRoute '/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute @@ -323,6 +330,7 @@ export interface FileRoutesByTo { '/settings/projects': typeof ChatSettingsProjectsRoute '/settings/providers': typeof ChatSettingsProvidersRoute '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/settings/secrets': typeof ChatSettingsSecretsRoute '/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute @@ -365,6 +373,7 @@ export interface FileRoutesById { '/_chat/settings/projects': typeof ChatSettingsProjectsRoute '/_chat/settings/providers': typeof ChatSettingsProvidersRoute '/_chat/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/_chat/settings/secrets': typeof ChatSettingsSecretsRoute '/_chat/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/_chat/settings/source-control': typeof ChatSettingsSourceControlRoute '/_chat/settings/storage': typeof ChatSettingsStorageRoute @@ -407,6 +416,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/secrets' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -447,6 +457,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/secrets' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -488,6 +499,7 @@ export interface FileRouteTypes { | '/_chat/settings/projects' | '/_chat/settings/providers' | '/_chat/settings/scheduled-tasks' + | '/_chat/settings/secrets' | '/_chat/settings/snap-shot' | '/_chat/settings/source-control' | '/_chat/settings/storage' @@ -626,6 +638,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSettingsSnapShotRouteImport parentRoute: typeof ChatSettingsRoute } + '/_chat/settings/secrets': { + id: '/_chat/settings/secrets' + path: '/secrets' + fullPath: '/settings/secrets' + preLoaderRoute: typeof ChatSettingsSecretsRouteImport + parentRoute: typeof ChatSettingsRoute + } '/_chat/settings/scheduled-tasks': { id: '/_chat/settings/scheduled-tasks' path: '/scheduled-tasks' @@ -804,6 +823,7 @@ interface ChatSettingsRouteChildren { ChatSettingsProjectsRoute: typeof ChatSettingsProjectsRoute ChatSettingsProvidersRoute: typeof ChatSettingsProvidersRoute ChatSettingsScheduledTasksRoute: typeof ChatSettingsScheduledTasksRoute + ChatSettingsSecretsRoute: typeof ChatSettingsSecretsRoute ChatSettingsSnapShotRoute: typeof ChatSettingsSnapShotRoute ChatSettingsSourceControlRoute: typeof ChatSettingsSourceControlRoute ChatSettingsStorageRoute: typeof ChatSettingsStorageRoute @@ -832,6 +852,7 @@ const ChatSettingsRouteChildren: ChatSettingsRouteChildren = { ChatSettingsProjectsRoute: ChatSettingsProjectsRoute, ChatSettingsProvidersRoute: ChatSettingsProvidersRoute, ChatSettingsScheduledTasksRoute: ChatSettingsScheduledTasksRoute, + ChatSettingsSecretsRoute: ChatSettingsSecretsRoute, ChatSettingsSnapShotRoute: ChatSettingsSnapShotRoute, ChatSettingsSourceControlRoute: ChatSettingsSourceControlRoute, ChatSettingsStorageRoute: ChatSettingsStorageRoute, diff --git a/apps/web/src/routes/_chat.settings.secrets.tsx b/apps/web/src/routes/_chat.settings.secrets.tsx new file mode 100644 index 0000000000000..d97e00fda19f7 --- /dev/null +++ b/apps/web/src/routes/_chat.settings.secrets.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { SecretsSettingsPanel } from "../components/settings/SecretsSettings"; +import { isSecretsFeatureEnabled } from "../env"; + +export const Route = createFileRoute("/_chat/settings/secrets")({ + beforeLoad: () => { + if (!isSecretsFeatureEnabled) throw redirect({ to: "/settings/general", replace: true }); + }, + component: SecretsSettingsPanel, +}); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index fdc350fe81ab0..7758c9ca55919 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -150,7 +150,7 @@ function SettingsRouteLayout() { return ( { // Send every axis so retain middleware recognizes "all" as an explicit target. void navigate({ diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 307b9c5d8284d..63f7cca9cbfc6 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -15,6 +15,7 @@ interface ImportMetaEnv { readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; readonly T3CODE_DESKTOP_UPDATE_REPOSITORY: string; + readonly T3CODE_FEATURE_SECRETS: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 501bac5dba020..da07814b29f6a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -49,6 +49,7 @@ const configuredDesktopUpdateRepository = process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || process.env.GITHUB_REPOSITORY?.trim() || ""; +const configuredFeatureSecrets = process.env.T3CODE_FEATURE_SECRETS?.trim() || ""; const configuredHostedAppUrl = (() => { const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); if (explicitHostedAppUrl) { @@ -316,6 +317,7 @@ export default defineConfig(() => { "import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY": JSON.stringify( configuredDesktopUpdateRepository, ), + "import.meta.env.T3CODE_FEATURE_SECRETS": JSON.stringify(configuredFeatureSecrets), "import.meta.env.VITE_RELEASE_CHANNEL": JSON.stringify(configuredReleaseChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 25523c2d0fb4a..fd25d9bb1719e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -15,6 +15,7 @@ import { resolveProjectProviderInstanceEnabled, resolveProviderInstanceEnabled, ServerSettings, + OnePasswordSecretProviderConfig, ServerSettingsPatch, withIdleCompaction, } from "./settings.ts"; @@ -25,6 +26,7 @@ const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const decodeOnePasswordConfig = Schema.decodeUnknownSync(OnePasswordSecretProviderConfig); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); describe("storage cleanup settings", () => { @@ -115,6 +117,54 @@ describe("ServerSettings usage price overrides", () => { }); }); +describe("ServerSettings secret providers", () => { + const onePassword = (account: string) => ({ + kind: "one_password", + label: "Personal", + config: { account }, + }); + + it("keeps an unknown kind or a longer label from invalidating the rest of the settings", () => { + const openbao = { kind: "openbao", label: "V".repeat(65), config: { address: "https://x" } }; + const decoded = decodeServerSettings({ + worktreesDirectory: "/work", + secretProviders: { vault: openbao, personal: onePassword("my.1password.com") }, + }); + expect(decoded.worktreesDirectory).toBe("/work"); + expect(encodeServerSettings(decoded).secretProviders).toEqual({ + vault: openbao, + personal: onePassword("my.1password.com"), + }); + }); + + it("accepts slug ids and null removals but no id that is not a slug", () => { + expect( + decodeServerSettingsPatch({ + secretProviders: { "1password": onePassword("my.1password.com"), work: null }, + }).secretProviders, + ).toEqual({ "1password": onePassword("my.1password.com"), work: null }); + for (const id of ["", "My Vault", "a/b", "-vault"]) { + expect(() => + decodeServerSettingsPatch({ secretProviders: { [id]: onePassword("my.1password.com") } }), + ).toThrow(); + } + expect(() => + decodeServerSettingsPatch({ + secretProviders: { work: { ...onePassword("my.1password.com"), label: "V".repeat(65) } }, + }), + ).toThrow(); + }); + + it("rejects a 1Password account op could misread", () => { + for (const account of ["", "my account", "--help"]) { + expect(() => decodeOnePasswordConfig({ account })).toThrow(); + } + expect(decodeOnePasswordConfig({ account: "my.1password.com" }).account).toBe( + "my.1password.com", + ); + }); +}); + describe("custom model settings", () => { const capabilities = { optionDescriptors: [ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 16eb51aa1769c..1339a9322fa3a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -859,6 +859,56 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; +// Final once shipped: a key or field that fails here fails the whole settings decode on older builds. +// Unlike provider slugs it may start with a digit, so a provider named "1Password" fits. +const SecretProviderSlug = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), +); + +export const SecretProviderId = SecretProviderSlug.pipe(Schema.brand("SecretProviderId")); +export type SecretProviderId = typeof SecretProviderId.Type; + +export const SecretProviderKind = SecretProviderSlug.pipe(Schema.brand("SecretProviderKind")); +export type SecretProviderKind = typeof SecretProviderKind.Type; + +const ONE_PASSWORD_ACCOUNT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** + * Any value `op --account` accepts: shorthand, sign-in address, account ID, or user ID. + * It is passed to the CLI as an argument, so it can never start with `-`. + */ +export const OnePasswordAccount = Schema.String.check( + Schema.isMaxLength(253), + Schema.makeFilter((value: string) => + ONE_PASSWORD_ACCOUNT_PATTERN.test(value) + ? true + : "1Password account must be a shorthand, sign-in address, or ID with no spaces.", + ), +).pipe(Schema.brand("OnePasswordAccount")); +export type OnePasswordAccount = typeof OnePasswordAccount.Type; + +export const SecretProviderLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); + +export const OnePasswordSecretProviderConfig = Schema.Struct({ + account: OnePasswordAccount, +}); +export type OnePasswordSecretProviderConfig = typeof OnePasswordSecretProviderConfig.Type; + +/** Kinds decode their own config; unknown-kind envelopes round-trip verbatim across versions. */ +export const SecretProviderConfig = Schema.Struct({ + kind: SecretProviderKind, + label: TrimmedNonEmptyString, + config: Schema.optionalKey(Schema.Unknown), +}); +export type SecretProviderConfig = typeof SecretProviderConfig.Type; + +// Limits that may change later are enforced on writes only, never on the stored envelope. +const SecretProviderConfigWrite = Schema.Struct({ + ...SecretProviderConfig.fields, + label: SecretProviderLabel, +}); + export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; @@ -1313,6 +1363,9 @@ export const ServerSettings = Schema.Struct({ usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + secretProviders: Schema.Record(SecretProviderId, SecretProviderConfig).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), cursorKeychainUsageEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1626,6 +1679,9 @@ export const ServerSettingsPatch = Schema.Struct({ usageLimitSources: Schema.optionalKey( Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), ), + secretProviders: Schema.optionalKey( + Schema.Record(SecretProviderId, Schema.NullOr(SecretProviderConfigWrite)), + ), cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey( @@ -1645,7 +1701,12 @@ export function requiredScopesForServerSettingsPatch( let changesSettings = false; for (const [key, value] of Object.entries(patch)) { if (value === undefined) continue; - if (key === "providers" || key === "providerInstances" || key === "usageLimitSources") { + if ( + key === "providers" || + key === "providerInstances" || + key === "usageLimitSources" || + key === "secretProviders" + ) { changesProviders = true; } else { changesSettings = true; diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f9a43b6123a1e..bd315271c78e1 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,8 +1,11 @@ import { DEFAULT_SERVER_SETTINGS, + OnePasswordAccount, ProjectId, ProviderDriverKind, ProviderInstanceId, + SecretProviderId, + SecretProviderKind, UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; @@ -544,6 +547,28 @@ describe("serverSettings helpers", () => { expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); }); + it("upserts and removes secretProviders per entry so concurrent edits cannot clobber", () => { + const personal = SecretProviderId.make("personal"); + const work = SecretProviderId.make("work"); + const onePassword = (label: string, account: string) => ({ + kind: SecretProviderKind.make("one_password"), + label, + config: { account: OnePasswordAccount.make(account) }, + }); + const current = { + ...DEFAULT_SERVER_SETTINGS, + secretProviders: { [personal]: onePassword("Personal", "my.1password.com") }, + }; + + const added = applyServerSettingsPatch(current, { + secretProviders: { [work]: onePassword("Work", "team.1password.com") }, + }); + expect(Object.keys(added.secretProviders)).toEqual([personal, work]); + + const removed = applyServerSettingsPatch(added, { secretProviders: { [personal]: null } }); + expect(Object.keys(removed.secretProviders)).toEqual([work]); + }); + it("replaces and removes individual usage prices without clobbering other models", () => { const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e7c856070658a..11eecc6fe3df8 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -357,6 +357,7 @@ export function applyServerSettingsPatch( worktreeCleanup: worktreeCleanupPatch, // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, + secretProviders: secretProvidersPatch, sourceControlHosts: sourceControlHostsPatch, usagePriceOverrides: usagePriceOverridesPatch, usageModelAliases: usageModelAliasesPatch, @@ -554,6 +555,9 @@ export function applyServerSettingsPatch( ), } : {}), + ...(secretProvidersPatch !== undefined + ? { secretProviders: mergeSettingsEntries(current.secretProviders, secretProvidersPatch) } + : {}), ...(usagePriceOverridesPatch !== undefined ? { usagePriceOverrides: mergeSettingsEntries( From d15278481b984379d1b639a93315c188b7e3fe67 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:39:01 -0500 Subject: [PATCH 06/86] fix(mobile): use monospace in the Android terminal preview (#23401) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../settings/appearance/components/AppearancePreviews.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index 644c6d25ee3d4..afcf4ba30d9a1 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -44,7 +44,7 @@ export function TerminalAppearancePreview(props: { readonly fontSize: number }) const theme = getMobileTerminalTheme(themeId, scheme); const lineHeight = Math.round(props.fontSize * 1.6); const lineStyle = { - fontFamily: "Menlo", + fontFamily: Platform.OS === "android" ? "monospace" : "Menlo", fontSize: props.fontSize, lineHeight, } as const; From ecea6ca3e60cadb95248732d37de0e715ed6ab78 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 29 Oct 2026 23:09:24 +0530 Subject: [PATCH 07/86] fix(web): edit from here uses a pencil icon (#21878) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/web/src/components/chat/TimelineUserMessageRow.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 2b231f6ddfe35..66cbb0bcc5ed4 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -34,7 +34,7 @@ import { type LucideIcon, MessagesSquareIcon, Redo2Icon, - Undo2Icon, + PencilIcon, } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; @@ -744,7 +744,7 @@ function RevertUserMessageButton({ /> } > - + Edit from here From b4da8962fc871e221d00ae2c2790b2984998bc1c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:39:47 -0500 Subject: [PATCH 08/86] fix(desktop): clear Cache Storage when deleting browser profile data (#22002) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/desktop/src/preview/BrowserSession.test.ts | 5 +++++ apps/desktop/src/preview/BrowserSession.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 22af4a829e049..b1f2b7ca0df92 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -280,11 +280,16 @@ describe("BrowserSession", () => { assert.isUndefined(sessions.get(partition)); yield* browserSessions.getSession("scope-untouched"); + const other = yield* browserSessions.getSession("scope-other"); yield* browserSessions.clearCookies([partition]); const created = sessions.get(partition); assert.isDefined(created); assert.strictEqual(created.clearStorageData.mock.calls.length, 1); + assert.deepStrictEqual(created.clearStorageData.mock.calls[0]?.[0], { + storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"], + }); + assert.strictEqual(vi.mocked(other.clearStorageData).mock.calls.length, 0); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 2ea7efdb88ee0..e467c370d0f5b 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -367,7 +367,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { Effect.tryPromise({ try: () => browserSession.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"], }), catch: (cause) => new BrowserSessionStorageClearError({ From 3d5baafecf5cb8ad22ca37962327fa4fdc130663 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 29 Oct 2026 17:40:10 +0000 Subject: [PATCH 09/86] fix(web): respect reduced motion in thread loading controls (#22100) Co-authored-by: SAPHID Co-authored-by: maria-rcks --- apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx | 5 +++-- apps/web/src/components/chat/ThreadRelationshipsControl.tsx | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index 2e00e47ee7da0..f1df96dd92133 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -3,7 +3,7 @@ import type { RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; -import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; +import { ChevronDownIcon, SquareIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { AgentElapsed } from "./AgentElapsed"; @@ -14,6 +14,7 @@ import { cn } from "../../lib/utils"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Spinner } from "../ui/spinner"; import { THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, @@ -158,7 +159,7 @@ export function ThreadLineageWorkflowRow({ } > {stopping ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index e107bbc4039c0..69403392b5ce8 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -49,7 +49,6 @@ import { ChevronDownIcon, CornerLeftUpIcon, GitForkIcon, - LoaderCircleIcon, MoreHorizontalIcon, PlusIcon, SquareIcon, @@ -76,6 +75,7 @@ import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; +import { Spinner } from "../ui/spinner"; import { cn } from "~/lib/utils"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, @@ -802,7 +802,7 @@ export function ThreadRelationshipsPanel(props: { onClick={() => void merge()} > {busyAction === "merge" ? ( - + ) : ( )} @@ -841,7 +841,7 @@ export function ThreadRelationshipsPanel(props: { } > {stoppingId === threadId ? ( - + ) : ( )} From 49d014245777bbf47756635886ddf0846d2330d3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:40:33 -0500 Subject: [PATCH 10/86] fix(provider-acp): redact API keys with underscores in ACP stderr logs (#24015) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- packages/provider-acp/src/server/stderr.test.ts | 13 +++++++++++++ packages/provider-acp/src/server/stderr.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/provider-acp/src/server/stderr.test.ts b/packages/provider-acp/src/server/stderr.test.ts index f582f47984ffb..fdf3c68f97e4d 100644 --- a/packages/provider-acp/src/server/stderr.test.ts +++ b/packages/provider-acp/src/server/stderr.test.ts @@ -50,4 +50,17 @@ describe("AcpStderr", () => { expect(excerpt).not.toContain("dXNlcjpwYXNz"); expect(excerpt).not.toContain("ant-api-key-value"); }); + + it("redacts the whole key when its body contains underscores", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "anthropic=sk-ant-api03-AbCdEf_GhIjKlMnOpQr-StUvWx_yz0123456789-AA", + "openai=sk-proj-AbCdEfGhIj_KlMnOpQrStUv-WxYz_0123456789", + ].join("\n"), + "/Users/ada", + {}, + ); + + expect(excerpt).toBe("anthropic=[redacted]\nopenai=[redacted]"); + }); }); diff --git a/packages/provider-acp/src/server/stderr.ts b/packages/provider-acp/src/server/stderr.ts index f988f2d632d97..92ae01a4c27ef 100644 --- a/packages/provider-acp/src/server/stderr.ts +++ b/packages/provider-acp/src/server/stderr.ts @@ -6,7 +6,7 @@ const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; const SECRET_TOKEN_PATTERN = - /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9_-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; export function appendAcpStderrTail(current: string, chunk: string): string { const next = `${current}${chunk}`; From 67afdd56c7905e9df721c61f5795578bb8c27867 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:40:55 -0500 Subject: [PATCH 11/86] fix(provider-core): redact compound credential keys in provider errors (#23313) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- packages/provider-core/src/server/failure.test.ts | 14 ++++++++++++++ packages/provider-core/src/server/failure.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index 8fb2e153aa109..a50d3dac9599c 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -41,6 +41,20 @@ it("redacts credentials and URL secrets from provider failures", () => { assert.notInclude(failure.message, "sk-abcdefghijklmnop"); }); +it("redacts credentials whose key names end in a credential word", () => { + const failure = makeProviderFailure({ + code: '{"refresh_token":"code value"}', + message: + 'oauth refresh failed: client_secret=client-value refresh_token=refresh-value {"client_secret":"json-value"} clientSecret: camel-value X-Auth-Token: header-value max_tokens=4096', + }); + + assert.equal(failure.code, '{"refresh_token":"[REDACTED]"}'); + assert.equal( + failure.message, + 'oauth refresh failed: client_secret=[REDACTED] refresh_token=[REDACTED] {"client_secret":"[REDACTED]"} clientSecret: [REDACTED] X-Auth-Token: [REDACTED] max_tokens=4096', + ); +}); + it("does not split a surrogate pair at the truncation boundary", () => { const failure = makeProviderFailure({ message: `${"a".repeat(MAX_PROVIDER_FAILURE_MESSAGE_LENGTH - 2)}🚀tail`, diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index b0ac1ed8b938d..2dd418ea2dfac 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -194,11 +194,11 @@ function redactProviderFailureText(value: string): string { .replace(/\bhttps?:\/\/[^\s<>"']+/giu, redactUrl) .replace(/\b(Bearer|Basic)\s+[^\s,;]+/giu, "$1 [REDACTED]") .replace( - /(["'](?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu, + /(["'][\w-]*(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu, "$1[REDACTED]$2", ) .replace( - /(\b(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu, + /((? Date: Thu, 29 Oct 2026 12:41:28 -0500 Subject: [PATCH 12/86] fix(web): show rounded diff counts as 1m instead of 1000k (#23723) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/web/src/components/chat/DiffStatLabel.test.ts | 12 ++++++++++++ apps/web/src/components/chat/DiffStatLabel.tsx | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/chat/DiffStatLabel.test.ts diff --git a/apps/web/src/components/chat/DiffStatLabel.test.ts b/apps/web/src/components/chat/DiffStatLabel.test.ts new file mode 100644 index 0000000000000..2ea8bcdb83559 --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatCompactDiffCount } from "./DiffStatLabel"; + +describe("formatCompactDiffCount", () => { + it("promotes counts when rounding reaches the next suffix", () => { + expect(formatCompactDiffCount(999_499)).toBe("999k"); + expect(formatCompactDiffCount(999_500)).toBe("1m"); + expect(formatCompactDiffCount(999_499_999)).toBe("999m"); + expect(formatCompactDiffCount(999_500_000)).toBe("1b"); + }); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 9e4c0f7cef5e9..2b60c35723a6f 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -5,13 +5,13 @@ export function hasNonZeroStat(stat: { additions: number; deletions: number }): return stat.additions > 0 || stat.deletions > 0; } -function formatCompactDiffCount(value: number): string { +export function formatCompactDiffCount(value: number): string { if (value < 1000) return String(value); - if (value < 1_000_000) { + if (value < 999_500) { const k = value / 1000; return `${k < 10 ? k.toFixed(1).replace(/\.0$/, "") : Math.round(k)}k`; } - if (value < 1_000_000_000) { + if (value < 999_500_000) { const m = value / 1_000_000; return `${m < 10 ? m.toFixed(1).replace(/\.0$/, "") : Math.round(m)}m`; } From 5e35f6ea1cec83da27a456818e5581dd24d325f2 Mon Sep 17 00:00:00 2001 From: tiliakoos <73005126+tiliakoos@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:41:51 -0400 Subject: [PATCH 13/86] fix(desktop): a timed-out login-shell probe no longer skips the server's PATH repair (#21771) Co-authored-by: maria-rcks --- .../src/shell/DesktopShellEnvironment.test.ts | 18 ++++++++++++++++-- .../src/shell/DesktopShellEnvironment.ts | 6 ++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 9d12ed5a07fe6..4804823069024 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -149,7 +149,7 @@ describe("DesktopShellEnvironment", () => { }; const commands: ChildProcess.Command[] = []; - yield* runShellEnvironment({ + const prepared = yield* runShellEnvironment({ env, platform: "darwin", handler: (command) => { @@ -167,6 +167,7 @@ describe("DesktopShellEnvironment", () => { assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); + assert.isTrue(prepared); }), ); @@ -267,7 +268,7 @@ describe("DesktopShellEnvironment", () => { }; const commands: string[] = []; - yield* runShellEnvironment({ + const prepared = yield* runShellEnvironment({ env, platform: "darwin", handler: (command) => { @@ -279,6 +280,19 @@ describe("DesktopShellEnvironment", () => { assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]); assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.isFalse(prepared); + }), + ); + + it.effect("leaves macOS preparation incomplete when no login shell returns a PATH", () => + Effect.gen(function* () { + const prepared = yield* runShellEnvironment({ + env: { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }, + platform: "darwin", + handler: () => "", + }); + + assert.isFalse(prepared); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 25c37ecefc051..6e0fdbd38050f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -392,7 +392,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return< - void, + boolean, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > { @@ -491,6 +491,8 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } } + + return Option.isSome(trimNonEmpty(shellEnvironment.PATH)); }, ); @@ -505,7 +507,7 @@ const installShellEnvironment = ( return installWindowsEnvironment(config); } if (config.platform === "darwin" || config.platform === "linux") { - return installPosixEnvironment(config).pipe(Effect.as(true)); + return installPosixEnvironment(config); } return Effect.succeed(false); }; From 55e7b5f66d6bd9f3e8e1ba06d20262afcc9ea64c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:42:15 -0500 Subject: [PATCH 14/86] fix(server): keep the previous preview browser version on a shared home (#23771) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../server/src/preview/PreviewBrowser.test.ts | 19 ++++++++++--------- apps/server/src/preview/PreviewBrowser.ts | 7 +++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index 0cb1665d4f3d4..a5c241229a377 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -73,6 +73,7 @@ const browserArchive = makeZip([ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( options: { + readonly version?: string; readonly archive?: Buffer; readonly sha256?: string; readonly body?: Stream.Stream; @@ -93,7 +94,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( release: options.unsupported ? null : { - version: "1.2.3", + version: options.version ?? "1.2.3", platform: "fixture", url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip", bytes: archive.byteLength, @@ -121,10 +122,11 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( }); it.layer(NodeServices.layer)("PreviewBrowser", (it) => { - it.effect("installs a verified download with its file modes and then reuses it", () => + it.effect.each(["0.8.0", "1.2.3"])("retains another version when installing %s", (current) => Effect.gen(function* () { - const { browser, fs, path, installRoot, requests } = yield* makeHarness(); - yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true }); + const { browser, fs, path, installRoot, requests } = yield* makeHarness({ version: current }); + yield* fs.makeDirectory(path.join(installRoot, "0.9.0"), { recursive: true }); + yield* fs.makeDirectory(path.join(installRoot, "0.10.0")); const active = path.join(installRoot, ".install-other"); const abandoned = path.join(installRoot, ".install-abandoned"); yield* fs.makeDirectory(active); @@ -135,7 +137,7 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { const executable = yield* browser.executable; - const version = path.join(installRoot, "1.2.3"); + const version = path.join(installRoot, current); expect(executable).toBe(path.join(version, executableName)); expect(yield* fs.readFileString(path.join(version, "locales", "en-US.pak"))).toBe("strings"); if (hostPlatform !== "win32") { @@ -145,10 +147,9 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { expect(yield* mode("libEGL.so")).toBe(0o755); expect(yield* mode(path.join("locales", "en-US.pak"))).toBe(0o644); } - expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual([ - ".install-other", - "1.2.3", - ]); + expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual( + [".install-other", "0.10.0", current].toSorted(), + ); expect(yield* browser.executable).toBe(executable); expect(yield* browser.installed).toEqual(Option.some(executable)); expect(requests).toHaveLength(1); diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index d13b495ef5c20..ea5539aad5e02 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -321,11 +321,14 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( yield* fs.remove(destination, { recursive: true, force: true }); yield* fs.rename(unpacked, destination); }).pipe(Effect.scoped); - // Shared homes may have another server installing; preserve staging directories touched within the last hour. + // Shared homes may still use the newest other version or staging directories touched within the last hour. const entries = yield* fs.readDirectory(installRoot); + const previous = entries + .filter((name) => name !== release.version && !name.startsWith(".")) + .toSorted((left, right) => right.localeCompare(left, "en", { numeric: true }))[0]; const now = yield* Clock.currentTimeMillis; yield* Effect.forEach( - entries.filter((name) => name !== release.version), + entries.filter((name) => name !== release.version && name !== previous), (name) => Effect.gen(function* () { const target = path.join(installRoot, name); From ee81ea4ce80ad21f5ee89b810127acf3ac126b1d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 10:42:31 -0700 Subject: [PATCH 15/86] fix(settings): servers check secret provider writes and keep unknown configs to themselves (#27044) Co-authored-by: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 18 ++++++++++++++ apps/server/src/serverSettings.ts | 17 ++++++++++++- apps/server/src/ws.ts | 1 + .../components/settings/SecretsSettings.tsx | 11 ++++++++- packages/contracts/src/server.ts | 2 ++ packages/contracts/src/settings.test.ts | 13 +++++----- packages/contracts/src/settings.ts | 24 +++++++++++++++++-- 7 files changed, 75 insertions(+), 11 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index bd985cab203ac..29dea381e759a 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -235,6 +235,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("sends clients a known secret provider's config but never an unknown kind's", () => + Effect.gen(function* () { + const settings = yield* decodeServerSettings({ + secretProviders: { + personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + vault: { kind: "openbao", label: "Vault", config: { token: "bao-token" } }, + }, + }); + assert.deepStrictEqual( + ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + { + personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + vault: { kind: "openbao", label: "Vault" }, + }, + ); + }), + ); + it.effect("saves through a symlinked settings file without replacing the link", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a1e0d34c7dd53..e386b95cf506c 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -7,6 +7,7 @@ import { type ProviderInstanceConfig, type ProviderInstanceMutation, defaultInstanceIdForDriver, + isKnownSecretProviderKind, isProviderDriverKind, ProviderDriverKind, ProviderInstanceId, @@ -128,11 +129,25 @@ const normalizeServerSettings = ( ); export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { - return mapSettingsSecrets(settings, (secret) => + const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); + return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; +} + +// A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. +function withoutUnknownSecretProviderConfigs( + settings: ServerSettings, +): ServerSettings["secretProviders"] { + return Object.fromEntries( + Object.entries(settings.secretProviders).map(([id, entry]) => { + if (isKnownSecretProviderKind(entry.kind)) return [id, entry]; + const { config: _config, ...envelope } = entry; + return [id, envelope]; + }), + ) as ServerSettings["secretProviders"]; } export function applyProviderInstanceMutation( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8c8509550bc11..86c7a4db56af4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1791,6 +1791,7 @@ export const layerWsRpc = ( threadSnapshotPagination: true, threadFind: true, threadFindProgressive: true, + secretProviderSettings: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), diff --git a/apps/web/src/components/settings/SecretsSettings.tsx b/apps/web/src/components/settings/SecretsSettings.tsx index e9efe2d85f6ed..2ff329ca6b73a 100644 --- a/apps/web/src/components/settings/SecretsSettings.tsx +++ b/apps/web/src/components/settings/SecretsSettings.tsx @@ -35,9 +35,18 @@ const SECRET_PROVIDER_KINDS: Readonly< export function SecretsSettingsPanel() { const { scope, environment } = useSettingsScope(); + const connected = environment?.connection.phase === "connected"; return ( - {environment?.connection.phase === "connected" ? ( + {connected && + environment.serverConfig !== null && + environment.serverConfig.secretProviderSettings !== true ? ( + +

+ Update T3 Code on {environment.label} to manage its secret providers. +

+
+ ) : connected ? ( { @@ -155,13 +153,14 @@ describe("ServerSettings secret providers", () => { ).toThrow(); }); - it("rejects a 1Password account op could misread", () => { + it("rejects writes of an unknown kind or an account op could misread", () => { + const write = (entry: unknown) => () => + decodeServerSettingsPatch({ secretProviders: { personal: entry } }); for (const account of ["", "my account", "--help"]) { - expect(() => decodeOnePasswordConfig({ account })).toThrow(); + expect(write(onePassword(account))).toThrow(); } - expect(decodeOnePasswordConfig({ account: "my.1password.com" }).account).toBe( - "my.1password.com", - ); + expect(write({ kind: "one_password", label: "Personal" })).toThrow(); + expect(write({ kind: "openbao", label: "Vault", config: { address: "https://x" } })).toThrow(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1339a9322fa3a..c7d61aeb5ee21 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -859,7 +859,7 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; -// Final once shipped: a key or field that fails here fails the whole settings decode on older builds. +// Final once shipped: older builds drop entries whose key fails here, and erase them on their next save. // Unlike provider slugs it may start with a digit, so a provider named "1Password" fits. const SecretProviderSlug = TrimmedNonEmptyString.check( Schema.isMaxLength(64), @@ -903,11 +903,31 @@ export const SecretProviderConfig = Schema.Struct({ }); export type SecretProviderConfig = typeof SecretProviderConfig.Type; +/** Kinds this build can resolve. Their `config` holds no credentials, so clients may read it. */ +const SECRET_PROVIDER_KIND_CONFIG_GUARDS: Readonly boolean>> = { + one_password: Schema.is(OnePasswordSecretProviderConfig), +}; + +export function isKnownSecretProviderKind(kind: string): boolean { + return Object.hasOwn(SECRET_PROVIDER_KIND_CONFIG_GUARDS, kind); +} + // Limits that may change later are enforced on writes only, never on the stored envelope. +// Writes must be a kind this build knows with a config it accepts, so an older server +// rejects a newer kind instead of storing what it cannot check. const SecretProviderConfigWrite = Schema.Struct({ ...SecretProviderConfig.fields, label: SecretProviderLabel, -}); +}).check( + Schema.makeFilter((entry) => { + if (!isKnownSecretProviderKind(entry.kind)) { + return `Unsupported secret provider kind: ${entry.kind}.`; + } + return SECRET_PROVIDER_KIND_CONFIG_GUARDS[entry.kind]!(entry.config) + ? true + : `Invalid ${entry.kind} secret provider config.`; + }), +); export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; From bfee44655ccbb4ec282f3cdd52e521b6258785db Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:42:37 -0500 Subject: [PATCH 16/86] fix(web): keep a closed split terminal's tab in view (#22106) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/web/src/terminalUiStateStore.test.ts | 22 ++++++++++++++++++++++ apps/web/src/terminalUiStateStore.ts | 8 +++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index 57e8836f27ff5..1ad8ba86c06ae 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -7,6 +7,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore, } from "./terminalUiStateStore"; +import { DEFAULT_THREAD_TERMINAL_ID } from "./types"; const THREAD_ID = ThreadId.make("thread-1"); const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); @@ -138,6 +139,27 @@ describe("terminalUiStateStore actions", () => { ]); }); + it.each([ + ["terminal-3", "terminal-3", DEFAULT_THREAD_TERMINAL_ID], + [DEFAULT_THREAD_TERMINAL_ID, DEFAULT_THREAD_TERMINAL_ID, "terminal-3"], + ["terminal-3", DEFAULT_THREAD_TERMINAL_ID, "terminal-3"], + ])("keeps the split's tab when %s is active and %s closes", (activeId, closedId, nextId) => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.newTerminal(THREAD_REF, "terminal-2"); + store.setActiveTerminal(THREAD_REF, DEFAULT_THREAD_TERMINAL_ID); + store.splitTerminal(THREAD_REF, "terminal-3"); + store.setActiveTerminal(THREAD_REF, activeId); + store.closeTerminal(THREAD_REF, closedId); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.activeTerminalId).toBe(nextId); + expect(terminalUiState.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); + }); + it("does not import a closed panel terminal from stale metadata", () => { const store = useTerminalUiStateStore.getState(); store.newTerminal(THREAD_REF, "term-2"); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 6629d8a6d99bf..b442e855ca12d 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -413,9 +413,15 @@ function closeThreadTerminal( } const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); + const groupTerminalIds = + normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId)) + ?.terminalIds ?? []; + const groupIndex = groupTerminalIds.indexOf(terminalId); const nextActiveTerminalId = normalized.activeTerminalId === terminalId - ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + ? (groupTerminalIds[groupIndex + 1] ?? + groupTerminalIds[groupIndex - 1] ?? + remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? remainingTerminalIds[0] ?? "") : normalized.activeTerminalId; From 69310dbd61a91db0f9ad7762a26d3c1b07710888 Mon Sep 17 00:00:00 2001 From: Onkar Pradip Jadhav <118719336+Onkarj012@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:12:59 +0530 Subject: [PATCH 17/86] fix(web): the ~ breadcrumb opens the home folder (#22412) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../web/src/components/files/filePath.test.ts | 28 ++++++++++++++++++- apps/web/src/components/files/filePath.ts | 3 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index 22cb582493df2..3bc371b5c1ab9 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { fileBreadcrumbChildren, fileBreadcrumbs, retainedBreadcrumbTrail } from "./filePath"; +import { + breadcrumbPathOf, + fileBreadcrumbChildren, + fileBreadcrumbs, + isListableHostFolder, + retainedBreadcrumbTrail, +} from "./filePath"; describe("fileBreadcrumbs", () => { it("starts host paths outside the workspace at the filesystem root", () => { @@ -20,6 +26,26 @@ describe("fileBreadcrumbs", () => { "\\\\server\\share\\report.md", ]); }); + + it("builds home-relative breadcrumbs with navigable folders", () => { + expect(fileBreadcrumbs("t3code", "~/.claude/CLAUDE.md")).toEqual([ + { label: "~", path: "~/", kind: "directory" }, + { label: ".claude", path: "~/.claude", kind: "directory" }, + { label: "CLAUDE.md", path: "~/.claude/CLAUDE.md", kind: "file" }, + ]); + expect(isListableHostFolder("~/")).toBe(true); + expect(isListableHostFolder("~/.claude")).toBe(true); + }); + + it("keeps a project folder named ~ inside the project", () => { + expect(fileBreadcrumbs("t3code", "~").map((crumb) => crumb.path)).toEqual(["", "~"]); + }); +}); + +describe("breadcrumbPathOf", () => { + it("keeps the home root listable", () => { + expect(breadcrumbPathOf("~/")).toBe("~/"); + }); }); describe("fileBreadcrumbChildren", () => { diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index 76e10839bb2c8..f9f3c9a5ac40a 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -23,7 +23,8 @@ export function fileBreadcrumbs(projectName: string, relativePath: string): File return { label: part, // A bare drive (`C:`) means the drive's current directory; its root is `C:\`. - path: /^[A-Za-z]:$/.test(path) ? `${path}\\` : path, + // A bare `~` is not a host path; the home folder is `~/`. + path: /^[A-Za-z]:$/.test(path) ? `${path}\\` : hostPath && path === "~" ? "~/" : path, kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), }; }), From 505d02282fc5ff398797df88be8a4160e56da412 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:43:22 -0500 Subject: [PATCH 18/86] fix(gitlab): accept merge request URLs from self-hosted hosts (#23848) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/pullRequestLinkInput.test.ts | 7 ++++++ .../src/shared/definition.test.ts | 22 +++++++++++++++++++ .../src/shared/definition.ts | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/pullRequestLinkInput.test.ts b/packages/client-runtime/src/pullRequestLinkInput.test.ts index 3747fdc833a4b..79bbc96bdea3b 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.test.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.test.ts @@ -161,6 +161,13 @@ describe("parsePullRequestReference", () => { ).toBe("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42"); }); + it.each([ + "https://code.example.com/group/project/-/merge_requests/42", + "http://git.internal/group/sub/project/-/merge_requests/7", + ])("accepts self-hosted GitLab merge request URL %s", (url) => { + expect(parsePullRequestReference(url)).toBe(url); + }); + it("accepts gh pr checkout commands with raw numbers", () => { expect(parsePullRequestReference("gh pr checkout 42")).toBe("42"); }); diff --git a/packages/source-control-gitlab/src/shared/definition.test.ts b/packages/source-control-gitlab/src/shared/definition.test.ts index a05ee047384db..7f9e94fa90887 100644 --- a/packages/source-control-gitlab/src/shared/definition.test.ts +++ b/packages/source-control-gitlab/src/shared/definition.test.ts @@ -31,6 +31,28 @@ describe("GitLab change request URLs", () => { expect(parse("https://gitlab.com/t3code/-/merge_requests/12")).toBeNull(); }); + it("recognizes only merge request URLs it can parse", () => { + for (const url of [ + "https://gitlab.com/g/p?x=/-/merge_requests/5", + "https://gitlab.com/g/p#/-/merge_requests/5", + "https://gitlab.com?x=/g/p/-/merge_requests/5", + "https://gitlab.com#/g/p/-/merge_requests/5", + "https://gitlab.com/t3code/-/merge_requests/12", + "https://gitlab.com/g//-/merge_requests/5", + ]) { + expect(definition.isChangeRequestReference(url), url).toBe(false); + expect(parse(url), url).toBeNull(); + } + for (const url of [ + "https://code.acme.test/a/b/c/-/merge_requests/7?tab=diffs", + "http://git.internal/a/b/c/-/merge_requests/7#note_1", + "https://code.acme.test/a/b/c/-/merge_requests/7/diffs", + ]) { + expect(definition.isChangeRequestReference(url), url).toBe(true); + expect(parse(url), url).toMatchObject({ repository: "a/b/c", number: 7 }); + } + }); + it("builds merge request URLs below the repository", () => { expect( definition.changeRequestUrl({ diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 1a3c417641ce4..437320c0d0419 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -11,7 +11,7 @@ import { icon } from "./icon.ts"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = - /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; + /^https?:\/\/[^/?#\s]+\/[^/?#\s]+(?:\/[^/?#\s]+)+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; // Self-managed GitLab runs on any hostname and nests projects in groups. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; From 2c3779762b5ae58c3ef4ae69bc9cb0b97ff74783 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:43:46 -0500 Subject: [PATCH 19/86] fix(server): commits no longer land on a branch switched in mid-action (#24473) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/git/GitManager.test.ts | 21 +++++++++++---------- apps/server/src/git/GitWorkflowService.ts | 10 ++++------ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 7f374a79934ce..5d363826458df 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3906,7 +3906,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { return { repoDir, manager, providerCalls, firstCommit, pullHead }; }); - it.effect.each(["switch", "create-and-switch", "feature-branch"] as const)( + it.effect.each(["switch", "create-and-switch", "feature-branch", "commit"] as const)( "local PR checkout excludes a concurrent %s from a nested project with an alternate root", (operation) => Effect.gen(function* () { @@ -3956,7 +3956,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ...manager, runStackedAction: (input, options) => Effect.sync(() => { - events.push("feature-branch"); + events.push(input.featureBranch ? "feature-branch" : "commit"); NodeFS.writeFileSync( NodePath.join(repoDir, "second-client.txt"), "second client\n", @@ -3993,9 +3993,9 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }) : workflows[1]!.runStackedAction({ cwd: nestedCwd, - actionId: "second-client-feature-branch", + actionId: `second-client-${operation}`, action: "commit", - featureBranch: true, + featureBranch: operation === "feature-branch", commitMessage: "Second client commit", }), { startImmediately: true }, @@ -4008,15 +4008,16 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(events).toEqual([operation]); expect(prepared).toMatchObject({ branch: "feature/pr-safe", isOnPullRequestHead: true }); expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainCommit); - expect((yield* runGit(repoDir, ["rev-parse", "feature/pr-safe"])).stdout.trim()).toBe( - pullHead, - ); + const pullBranch = operation === "commit" ? "feature/pr-safe^" : "feature/pr-safe"; + expect((yield* runGit(repoDir, ["rev-parse", pullBranch])).stdout.trim()).toBe(pullHead); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe( operation === "switch" ? "main" - : "branch" in switched - ? switched.branch.name - : "second-client", + : operation === "commit" + ? "feature/pr-safe" + : "branch" in switched + ? switched.branch.name + : "second-client", ); if ("commit" in switched) { expect(switched.commit.status).toBe("created"); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 7bb098da87862..fdc00849af2d0 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -377,12 +377,10 @@ export const make = Effect.gen(function* () { runStackedAction: Effect.fn("GitWorkflowService.runStackedAction")(function (input, options) { return ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.flatMap((handle) => - input.featureBranch - ? withCheckoutLease( - handle.repository.rootPath, - gitManager.runStackedAction(input, options), - ) - : gitManager.runStackedAction(input, options), + withCheckoutLease( + handle.repository.rootPath, + gitManager.runStackedAction(input, options), + ), ), ); }), From 3b066dad5c7fa1db5803d655da5a803dde9c77d1 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:44:08 -0400 Subject: [PATCH 20/86] fix(server): image attachments report their size when the T3 home is behind a symlink (#24245) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/assets/AssetAccess.test.ts | 27 ++++++++++++++++++++++ apps/server/src/assets/AssetAccess.ts | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 39a6e651b84dd..11cb490901a52 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1199,6 +1199,33 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect.skipIf(!symlinksSupported)( + "reports image attachment dimensions when the attachments directory is a symlink", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-attachments-link-" }); + const realAttachmentsDir = path.join(root, "real"); + const linkedAttachmentsDir = path.join(root, "linked"); + yield* fs.makeDirectory(realAttachmentsDir); + yield* fs.symlink(realAttachmentsDir, linkedAttachmentsDir); + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000041"; + yield* fs.writeFile(path.join(realAttachmentsDir, `${attachmentId}.png`), screenshotPng); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }).pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + attachmentsDir: linkedAttachmentsDir, + }), + ); + expect(result.imageDimensions).toEqual({ width: 390, height: 844 }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("serves document attachments inline when a viewer requests it", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 0ca85e6ea146e..8eb7894627230 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -659,7 +659,10 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ? inlinePreviewMimeTypeForExtension(extension) : undefined; if (!isGenericFile) { - imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath); + imageDimensions = yield* fileSystem.realPath(attachmentPath).pipe( + Effect.flatMap(readImageDimensionsFromHeader), + Effect.orElseSucceed(() => null), + ); } claims = { version: 1, From ebc34dae6058a0b4ce05c3dc34c8fbf7cbe6e46e Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:44:30 +0000 Subject: [PATCH 21/86] fix(ssh): skip directories in config includes (#25187) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- packages/ssh/src/config.test.ts | 33 +++++++++++++++++++++++++++++++++ packages/ssh/src/config.ts | 1 + 2 files changed, 34 insertions(+) diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 6871ebcfd18c9..82a6319aefd63 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -56,6 +56,39 @@ describe("ssh config", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); + it.effect("skips directory Include matches without losing file aliases or known hosts", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* makeTempHomeDir(); + const sshDir = path.join(homeDir, ".ssh"); + yield* fs.makeDirectory(path.join(sshDir, "config.d", "ignored"), { recursive: true }); + yield* fs.makeDirectory(path.join(sshDir, "explicit-directory")); + yield* fs.writeFileString( + path.join(sshDir, "config"), + "Include config.d/*\nInclude explicit-directory\nHost real\n HostName real.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "config.d", "team.conf"), + "Host team\n HostName team.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "known_hosts"), + "known.example.com ssh-ed25519 AAAA\n", + ); + + const hosts = yield* discoverSshHosts({ homeDir }); + assert.deepEqual( + hosts.map((host) => [host.alias, host.hostname, host.source]), + [ + ["known.example.com", "known.example.com", "known-hosts"], + ["real", "real.example.com", "ssh-config"], + ["team", "team.example.com", "ssh-config"], + ], + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("discovers ssh config hosts across included files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index 634d38aa0f7f9..8b6683a968a38 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -177,6 +177,7 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( return; } if (!(yield* fs.exists(resolvedPath))) return; + if ((yield* fs.stat(resolvedPath)).type === "Directory") return; visited.add(resolvedPath); const directory = path.dirname(resolvedPath); From 80dfe47ea529cc37a1c7833b1aeebed93d3a6976 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:45:04 -0400 Subject: [PATCH 22/86] fix(server): Authorized clients names Edge, Firefox and Opera on phones correctly (#23340) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/auth/utils.test.ts | 29 +++++++++++++++++++++++++++++ apps/server/src/auth/utils.ts | 6 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 63336b29c9005..30784ab33ceb0 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -24,6 +24,35 @@ describe("deriveAuthClientMetadata", () => { }); }); + it.each([ + [ + "Edge on Android", + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 EdgA/129.0.0.0", + "Edge", + ], + [ + "Edge on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/129.0.2792.84 Mobile/15E148 Safari/605.1.15", + "Edge", + ], + [ + "Firefox on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/131.0 Mobile/15E148 Safari/605.1.15", + "Firefox", + ], + [ + "Opera on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 OPiOS/5.0.0 Mobile/15E148 Safari/605.1.15", + "Opera", + ], + ])("labels %s by its own browser token", (_name, userAgent, browser) => { + const metadata = deriveAuthClientMetadata({ + request: { headers: { "user-agent": userAgent }, source: {} } as never, + }); + + expect(metadata.browser).toBe(browser); + }); + it("applies client-presented display identity without replacing transport metadata", () => { const metadata = deriveAuthClientMetadata({ request: { diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index dc89a5da81140..29c0c5100a190 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -128,9 +128,9 @@ function inferBrowser(userAgent: string | undefined): string | undefined { return undefined; } const normalized = userAgent.toLowerCase(); - if (/edg\//.test(normalized)) return "Edge"; - if (/opr\//.test(normalized)) return "Opera"; - if (/firefox\//.test(normalized)) return "Firefox"; + if (/edg(?:e|a|ios)?\//.test(normalized)) return "Edge"; + if (/opr\/|opios\//.test(normalized)) return "Opera"; + if (/firefox\/|fxios\//.test(normalized)) return "Firefox"; if (/electron\//.test(normalized)) return "Electron"; if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome"; if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari"; From 1d7af6591b8d6011bc449596d2be6184ab6c5c67 Mon Sep 17 00:00:00 2001 From: Jeremiah Jordan Date: Thu, 29 Oct 2026 12:45:29 -0500 Subject: [PATCH 23/86] fix(mobile): fork rows show a readable label instead of a thread ID (#22064) Co-authored-by: Jeremiah Jordan Co-authored-by: GPT-6.1-Sol Co-authored-by: maria-rcks --- apps/mobile/src/lib/threadActivity.test.ts | 35 ++++++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 1 + 2 files changed, 36 insertions(+) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index acdee2249c0d0..f2ced2d689794 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -24,6 +24,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadFeed, + workEntryRowLabel, threadFeedCompactionLabels, createThreadFeedCache, deriveThreadFeedPresentation, @@ -731,6 +732,40 @@ describe("buildThreadFeed", () => { expect(activities.at(-1)?.prominent).toBe(true); }); + it.each([ + ["local", undefined], + ["inherited", undefined], + ["synthetic", undefined], + ["synthetic", "Forked from conversation"], + ] as const)( + "labels a %s fork row with title %s without exposing its target ID", + (visibility, title) => { + const { providerThreadId: _providerThreadId, ...forkBase } = base( + "item-fork", + "2026-06-20T00:00:03.000Z", + 2, + ); + const activity = buildThreadFeed([ + projected( + { + ...forkBase, + type: "fork", + title: title ?? null, + source: { type: "run", threadId: sourceThreadId, runId }, + targetThreadId: threadId, + }, + 0, + visibility, + ), + ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0]!; + + expect(workEntryRowLabel(activity.workEntry)).toBe(title ?? "Thread forked"); + expect(workEntryRowLabel(activity.workEntry, true)).toBe(title ?? "Thread forked"); + expect(activity.canExpand).toBe(true); + expect(activity.getFullDetail()).toContain(threadId); + }, + ); + it("names the fold that hides a progress message, with or without a run", () => { const middle = { ...assistantMessage("2026-06-20T00:00:02.500Z"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e17748d77aa6c..4cc5a842c21f9 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -253,6 +253,7 @@ function compactWorkEntryText(value: string): string { } export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = false): string { + if (entry.itemType === "fork") return entry.label; if (expanded && entry.itemType === "reasoning") return entry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought"; const presentation = resolveWorkEntryToolPresentation(entry); From 1d631342b01d0231cd1380606a0a0c5180322762 Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Thu, 29 Oct 2026 18:45:51 +0100 Subject: [PATCH 24/86] fix(markdown): formulas ending in a comma render (#22883) Co-authored-by: maria-rcks --- packages/shared/src/markdownMath.test.ts | 21 +++++++++++++++++++ .../micromark-extension-llm-math@3.1.0.patch | 10 +++++++-- pnpm-lock.yaml | 6 +++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/markdownMath.test.ts b/packages/shared/src/markdownMath.test.ts index cb920c0737972..0bc6941e494c0 100644 --- a/packages/shared/src/markdownMath.test.ts +++ b/packages/shared/src/markdownMath.test.ts @@ -124,8 +124,18 @@ describe("chat math parsing", () => { "It costs $5 and $10.", "Budget $5-$10 per seat, or $20,000-$30,000 a year.", "Plans are $5/$10 monthly.", + "Pay $5,$10 now.", "Run $browser then $deploy on it.", "Set $HOME before running.", + "Set $HOME,$PATH before running.", + "Set $HOME, $PATH before running.", + "Set $HOME,$_PATH before running.", + "Set $home,$path before running.", + "Set $HOME,${PATH} before running.", + "Set $HOME,$? before running.", + "Set $HOME,$@ before running.", + "Set $HOME,$(pwd) before running.", + "Pay $1,000,$2,000 now.", ]) { expect(formulas(prose), prose).toEqual([]); expect(text(prose), prose).toBe(prose); @@ -143,6 +153,17 @@ describe("chat math parsing", () => { } }); + it("reads formulas that end in a comma", () => { + for (const boundary of ["", " ", "\n", ".", ",", ";", ":", ")", "]", "}"]) { + expect(formulas(`$x,$${boundary}`)).toEqual([["inline", "x,"]]); + } + expect(formulas("Where $x = 1,$ with $$a,$$ and $$ b,$$.")).toEqual([ + ["inline", "x = 1,"], + ["display", "a,"], + ["display", " b,"], + ]); + }); + it("leaves code, escapes, links, and citations alone", () => { for (const [markdown, rendered] of [ ["Use `$x$` in code.", "Use in code."], diff --git a/patches/micromark-extension-llm-math@3.1.0.patch b/patches/micromark-extension-llm-math@3.1.0.patch index 335ce4c11d57e..25da4f5f47755 100644 --- a/patches/micromark-extension-llm-math@3.1.0.patch +++ b/patches/micromark-extension-llm-math@3.1.0.patch @@ -235,6 +235,9 @@ diff --git a/dev/lib/math-tex-text.js b/dev/lib/math-tex-text.js diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js --- a/dev/lib/math-text.js +++ b/dev/lib/math-text.js +@@ -10 +10 @@ +-import {markdownLineEnding} from 'micromark-util-character' ++import {markdownLineEnding, markdownLineEndingOrSpace} from 'micromark-util-character' @@ -42,6 +42,13 @@ let size /** @type {Token} */ @@ -299,7 +302,7 @@ diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js if ( code === codes.eof || code === codes.space || -@@ -179,9 +207,26 @@ +@@ -179,9 +207,29 @@ // Done! if (size === sizeOpen) { @@ -314,11 +317,14 @@ diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js + if (code !== null && code >= codes.digit0 && code <= codes.digit9) { + return nok(code) + } ++ if (charBeforeClose === codes.comma && !openWhitespace && code !== codes.eof && !markdownLineEndingOrSpace(code) && !/[.,;:)\]}]/.test(String.fromCharCode(code))) { ++ return nok(code) ++ } + // Check if closing sequence has matching whitespace pattern + const closeWhitespace = + charBeforeClose === codes.space || + charBeforeClose === codes.horizontalTab || -+ charBeforeClose === codes.comma ++ (charBeforeClose === codes.comma && openWhitespace) + if (openWhitespace === closeWhitespace) { + effects.exit('mathTextSequence') + effects.exit('mathText') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bff27f291a17..196781c388b03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,7 +380,7 @@ patchedDependencies: expo-widgets@58.0.20: 8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171 mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 - micromark-extension-llm-math@3.1.0: 49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9 + micromark-extension-llm-math@3.1.0: 7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 react-native-image-viewing@0.2.2: 419f35ee91eac88ced9438058281db5eb09d9c799d8974c1172926068c9448cf @@ -20056,7 +20056,7 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-llm-math@3.1.0(patch_hash=49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9): + micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a): dependencies: '@types/katex': 0.16.8 devlop: 1.1.0 @@ -21348,7 +21348,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 mdast-util-math: 3.0.0(patch_hash=afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c)(supports-color@8.1.1) - micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9) + micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a) unified: 11.0.5 transitivePeerDependencies: - supports-color From 20490521fa4adc3b16a652bc3592405d0ec4a790 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:46:14 -0400 Subject: [PATCH 25/86] fix(scripts): dev runner expands ~ in --home-dir and T3CODE_HOME (#25560) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- scripts/dev-runner.test.ts | 31 +++++++++++++++++++++++++++++++ scripts/dev-runner.ts | 6 ++++++ 2 files changed, 37 insertions(+) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 895144079fc5b..d522bfdb15794 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -412,6 +412,37 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("expands a home-relative T3 home the way the server does", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homeFor = (t3Home: string) => + createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }).pipe( + Effect.provideService(HostProcess.HomeDirectory, "/home/dev"), + Effect.map((env) => env.T3CODE_HOME), + ); + + assert.equal(yield* homeFor("~/t3-dev"), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor("~"), "/home/dev"); + assert.equal(yield* homeFor("~\\t3-dev"), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor(" ~/t3-dev "), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor("~someone/t3-dev"), path.resolve("~someone/t3-dev")); + assert.equal(yield* homeFor("relative/t3-dev"), path.resolve("relative/t3-dev")); + assert.equal(yield* homeFor("/tmp/t3-dev"), path.resolve("/tmp/t3-dev")); + }), + ); + it.effect("rejects a specific non-loopback --host for browser dev modes", () => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 0495c513dc685..5903cb7686e9c 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -272,6 +272,12 @@ const resolveBaseDir = Effect.fn("dev-runner.resolveBaseDir")(function* ( const path = yield* Path.Path; const configured = baseDir?.trim(); + if (configured === "~") { + return yield* HostProcess.HomeDirectory; + } + if (configured?.startsWith("~/") || configured?.startsWith("~\\")) { + return path.join(yield* HostProcess.HomeDirectory, configured.slice(2)); + } if (configured) { return path.resolve(configured); } From 1dd8c676e43f1fd6dee98fa81bdec4a5c3750bab Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:46:36 -0500 Subject: [PATCH 26/86] fix(desktop): keep development protocol requests on the configured server (#22010) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/electron/ElectronProtocol.test.ts | 33 +++++++++++++++++++ apps/desktop/src/electron/ElectronProtocol.ts | 5 ++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 45d1771f26f07..aec3a2d5b45c0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -130,6 +130,39 @@ describe("ElectronProtocol", () => { }).pipe(Effect.provide(layerProtocol)), ); + it.effect("keeps proxied paths on the configured server", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockImplementation(async () => new Response("ok")); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:5733/"), + clerkFrontendApiHostname: undefined, + }); + yield* Effect.promise(() => + handler!(new Request("t3code-dev://app//example.com/src/main.tsx?t=1")), + ); + yield* Effect.promise(() => handler!(new Request("t3code-dev://app/\\example.com/"))); + }), + ); + + assert.deepEqual( + netFetchMock.mock.calls.map((call) => call[0]), + [ + "http://127.0.0.1:5733//example.com/src/main.tsx?t=1", + "http://127.0.0.1:5733//example.com/", + ], + ); + }).pipe(Effect.provide(layerProtocol)), + ); + it.effect("rejects custom protocol requests for another host", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d84b6f360c89e..27af3f2cfd8ae 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -167,7 +167,10 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* ( return new Response(null, { status: 404 }); } - const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin); + // Assign the path so leading slashes cannot replace the configured host. + const targetUrl = new URL(targetOrigin); + targetUrl.pathname = requestUrl.pathname; + targetUrl.search = requestUrl.search; const headers = new Headers(request.headers); const headersToRemove: string[] = []; for (const name of headers.keys()) { From e0f972e3759c628b8c202930d5d38a53d4569b72 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:46:58 -0500 Subject: [PATCH 27/86] fix(mobile): apply terminal toolbar modifiers to arrow keys (#22161) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../features/terminal/terminalInput.test.ts | 32 +++++++++++++++++++ .../src/features/terminal/terminalInput.ts | 8 +++++ 2 files changed, 40 insertions(+) diff --git a/apps/mobile/src/features/terminal/terminalInput.test.ts b/apps/mobile/src/features/terminal/terminalInput.test.ts index 74240215af08a..17fabe588115c 100644 --- a/apps/mobile/src/features/terminal/terminalInput.test.ts +++ b/apps/mobile/src/features/terminal/terminalInput.test.ts @@ -12,6 +12,38 @@ const ESC = byte(0x1b); const CTRL_V = byte(0x16); describe("resolveModifiedTerminalInput", () => { + it.each(["A", "B", "C", "D"])("encodes ctrl+arrow %s with the cursor modifier", (arrow) => { + expect( + resolveModifiedTerminalInput({ + data: `${ESC}[${arrow}`, + modifier: "ctrl", + hostPlatform: "linux", + }), + ).toEqual({ kind: "write", data: `${ESC}[1;5${arrow}` }); + }); + + it.each(["A", "B", "C", "D"])("encodes meta+arrow %s with the cursor modifier", (arrow) => { + expect( + resolveModifiedTerminalInput({ + data: `${ESC}[${arrow}`, + modifier: "meta", + hostPlatform: "linux", + }), + ).toEqual({ kind: "write", data: `${ESC}[1;3${arrow}` }); + }); + + it.each(["a", "", ESC, `${ESC}[H`, `${ESC}[1;5D`, `${ESC}[D\n`])( + "preserves non-arrow modifier handling for %j", + (data) => { + expect( + resolveModifiedTerminalInput({ data, modifier: "meta", hostPlatform: "linux" }), + ).toEqual({ + kind: "write", + data: `${ESC}${data}`, + }); + }, + ); + it("pastes on ctrl+v for windows, linux, and unknown hosts", () => { for (const hostPlatform of ["windows", "linux", "unknown"] as const) { expect(resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform })).toEqual({ diff --git a/apps/mobile/src/features/terminal/terminalInput.ts b/apps/mobile/src/features/terminal/terminalInput.ts index 09886b41cb2bb..9c5e05dcc24f0 100644 --- a/apps/mobile/src/features/terminal/terminalInput.ts +++ b/apps/mobile/src/features/terminal/terminalInput.ts @@ -45,6 +45,14 @@ export function resolveModifiedTerminalInput(input: { return { kind: "paste" }; } + if ( + input.data.length === 3 && + input.data.startsWith("\u001b[") && + /[ABCD]/.test(input.data.charAt(2)) + ) { + return { kind: "write", data: `\u001b[1;${input.modifier === "ctrl" ? 5 : 3}${input.data[2]}` }; + } + return { kind: "write", data: input.modifier === "ctrl" ? applyCtrlModifier(input.data) : `\u001b${input.data}`, From 40f6f5b8f033628a672b7a9213c27cd64db05cb3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:47:21 -0500 Subject: [PATCH 28/86] fix(server): clean old logs and artifacts under a symlinked T3 home (#24097) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/storageCleanup.test.ts | 36 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 4 ++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c05d4aed4cfbf..9cbf0f092f7bd 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1814,6 +1814,42 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live.each([ + { linked: "home", outcome: "removed", reason: "Removed 1 rotated logs" }, + { linked: "logs folder", outcome: "kept", reason: "No expired files" }, + ] as const)( + "rotated logs behind a symlinked $linked are $outcome", + ({ linked, outcome, reason }) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const disk = yield* fs.realPath(yield* fs.makeTempDirectoryScoped()); + yield* fs.makeDirectory(`${disk}/real/logs`, { recursive: true }); + if (linked === "home") { + yield* fs.symlink(`${disk}/real`, `${disk}/home`); + } else { + yield* fs.makeDirectory(`${disk}/home`); + yield* fs.symlink(`${disk}/real/logs`, `${disk}/home/logs`); + } + yield* fs.writeFileString(`${disk}/real/logs/server.log.1`, "log"); + yield* fs.utimes(`${disk}/real/logs/server.log.1`, 1, 1); + const { service, enableFiles, disable } = yield* cleanupFixture().pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + logsDir: `${disk}/home/logs`, + }), + ); + disable(); + enableFiles(); + const report = yield* service.runNow; + expect(report.entries.filter((entry) => entry.kind === "logs")).toMatchObject([ + { outcome, reason }, + ]); + expect(yield* fs.exists(`${disk}/real/logs/server.log.1`)).toBe(outcome === "kept"); + }), + ), + ); it.live("reports git failures without aborting the remaining categories", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 526aaffa2abb3..9bf040b0cd17c 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1059,8 +1059,10 @@ const make = Effect.gen(function* () { removed: { files: number; bytes: number }, ) { if (days === null || !(yield* fs.exists(root))) return removed; + // A symlinked parent (a linked T3 home) is fine; a symlinked folder is not. const realRoot = yield* fs.realPath(root); - if (realRoot !== path.resolve(root)) return removed; + const realParent = yield* fs.realPath(path.dirname(root)); + if (realRoot !== path.join(realParent, path.basename(root))) return removed; const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( directory: string, ): Effect.fn.Return { From b02b436e26f9c0c6c193ea2af22a50885ffe8aac Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:47:44 +0000 Subject: [PATCH 29/86] fix(server): preserve URL arguments in Android device actions (#25205) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- apps/server/src/device/DeviceActions.test.ts | 38 ++++++++++++++++++++ apps/server/src/device/DeviceActions.ts | 6 +++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 41e1de4304692..1a8cab7f29837 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -1,5 +1,9 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import type { DeviceHostReady } from "./DeviceHost.ts"; @@ -43,6 +47,40 @@ const makeReady = ( const udid = "SIM-1"; describe("runDeviceAction", () => { + it.live.each([ + "https://example.com/callback?code=1&state=2", + "https://example.com/it's?a=1&b=2", + "https://example.com/a b?key=value", + "https://example.com/plain", + ])("opens the complete Android URL %s", (url) => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const { ready, calls } = makeReady(); + yield* runDeviceAction(ready, "android", { + type: "openUrl", + deviceId: "emulator-5554", + url, + }); + const args = calls[0]!.args; + expect(args.slice(0, 3)).toEqual(["-s", "emulator-5554", "shell"]); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // adb joins arguments verbatim before the Android shell parses them. + const stdout = yield* spawner.string( + ChildProcess.make("/bin/sh", [ + "-c", + `am() { printf '%s\\0' "$@"; }\n${args.slice(3).join(" ")}`, + ]), + ); + expect(stdout.split("\0").slice(0, -1)).toEqual([ + "start", + "-a", + "android.intent.action.VIEW", + "-d", + url, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each([ ["emulator-5554", "Physical size: 2560x1600", "portrait", "-9.81:0:0"], ["emulator-5554", "Physical size: 2560x1600", "landscape_left", "0:9.81:0"], diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 002d8cb3e7d28..905a23d5cc726 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -342,13 +342,17 @@ const serveSimPermissions = Effect.fn("DeviceActions.serveSimPermissions")(funct const adb = (run: Runner, serial: string, args: ReadonlyArray, operation: string) => run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation))); +// adb shell joins arguments without escaping before the device's shell parses them. +const androidShellWord = (value: string) => + /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; + const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( run: Runner, input: DeviceActionInput, ) { const serial = input.deviceId; const shell = (args: ReadonlyArray, operation: string) => - adb(run, serial, ["shell", ...args], operation); + adb(run, serial, ["shell", ...args.map(androidShellWord)], operation); switch (input.type) { case "setAppearance": yield* shell(["cmd", "uimode", "night", input.value === "dark" ? "yes" : "no"], "appearance"); From c839797b6dceaa28cc61b7044fcae36bd7a11462 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:48:06 +0000 Subject: [PATCH 30/86] fix(server): keep terminal history after cancelled sequences (#25193) Co-authored-by: Aditya Garud <153842990+yashranaway@users.noreply.github.com> --- apps/server/src/terminal/Manager.test.ts | 29 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 16 +++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index e30a4cd3d720d..5b166d8b1452b 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -881,6 +881,35 @@ it.layer( }), ); + it.effect.each([ + { name: "OSC", sequence: "\u001b]0;unfinished" }, + { name: "DCS", sequence: "\u001bPunfinished" }, + { name: "PM", sequence: "\u001b^unfinished" }, + { name: "APC", sequence: "\u001b_unfinished" }, + { name: "CSI", sequence: "\u001b[5" }, + { name: "C1 OSC", sequence: "\u009d0;unfinished" }, + { name: "C1 DCS", sequence: "\u0090unfinished" }, + { name: "C1 PM", sequence: "\u009eunfinished" }, + { name: "C1 APC", sequence: "\u009funfinished" }, + { name: "C1 CSI", sequence: "\u009b5" }, + ])("keeps output after a cancelled $name sequence in history", ({ sequence }) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + for (const cancel of ["\u0018", "\u001a"]) { + process.emitData(sequence); + process.emitData(cancel); + process.emitData("next line\n"); + process.emitData(`${sequence}${cancel}next line\n`); + } + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(reopened.history.match(/next line/g)).toHaveLength(4); + }), + ); + it.effect("handles CSI and DCS query sequences split across output chunks", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4c5229a7bae4f..a08b3e4afe784 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1067,6 +1067,10 @@ function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } +function isControlSequenceCancel(codePoint: number): boolean { + return codePoint === 0x18 || codePoint === 0x1a; +} + function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "n") { return true; @@ -1115,7 +1119,7 @@ function stripStringTerminator(value: string): string { function findStringTerminatorIndex(input: string, start: number): number | null { for (let index = start; index < input.length; index += 1) { const codePoint = input.charCodeAt(index); - if (codePoint === 0x07 || codePoint === 0x9c) { + if (codePoint === 0x07 || codePoint === 0x9c || isControlSequenceCancel(codePoint)) { return index + 1; } if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { @@ -1177,10 +1181,11 @@ function sanitizeTerminalHistoryChunk( if (nextCodePoint === 0x5b) { let cursor = index + 2; while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { + const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); + if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 2, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; @@ -1231,10 +1236,11 @@ function sanitizeTerminalHistoryChunk( if (codePoint === 0x9b) { let cursor = index + 1; while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { + const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); + if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 1, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; From 5aa81a31718dcd667c502022a568b9ca39c02b7f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:48:28 -0500 Subject: [PATCH 31/86] fix(auth): accept a typed pairing code regardless of letter case (#22089) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../server/src/auth/PairingGrantStore.test.ts | 33 +++++++++++++++++-- apps/server/src/auth/PairingGrantStore.ts | 14 ++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 561633d3524b2..4e940cd36cc51 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,6 +1,7 @@ import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -45,11 +46,11 @@ const layerPairingGrantStore = ( ); it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { - it.effect("issues one-time bootstrap tokens that can only be consumed once", () => + it.effect("accepts lowercase one-time tokens exactly once", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); - const first = yield* bootstrapCredentials.consume(issued.credential); + const first = yield* bootstrapCredentials.consume(issued.credential.toLowerCase()); const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); @@ -59,7 +60,16 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(issued.label).toBe("Julius iPhone"); expect(second._tag).toBe("UnknownBootstrapCredentialError"); expect(second.message).toContain("Unknown bootstrap credential"); - }).pipe(Effect.provide(layerPairingGrantStore())), + }).pipe( + Effect.provide(layerPairingGrantStore()), + Effect.provideServiceEffect( + Crypto.Crypto, + Effect.map(Crypto.Crypto, (crypto) => ({ + ...crypto, + randomBytes: (size) => Effect.succeed(new Uint8Array(size).fill(8)), + })), + ), + ), ); it.effect("atomically consumes a one-time token when multiple requests race", () => @@ -124,6 +134,23 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { }).pipe(Effect.provide(layerPairingGrantStore())), ); + it.effect("matches a seeded desktop bootstrap credential exactly", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const exact = yield* bootstrapCredentials.consume("abcdefghjkmn"); + const otherCase = yield* Effect.flip(bootstrapCredentials.consume("ABCDEFGHJKMN")); + + expect(exact.method).toBe("desktop-bootstrap"); + expect(otherCase._tag).toBe("UnknownBootstrapCredentialError"); + }).pipe( + Effect.provide( + layerPairingGrantStore({ + desktopBootstrapToken: "abcdefghjkmn", + }), + ), + ), + ); + it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 4999fef9c49b7..440dbe3788a45 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -254,6 +254,11 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; +const PAIRING_TOKEN_PATTERN = new RegExp( + `^[${PAIRING_TOKEN_ALPHABET}]{${PAIRING_TOKEN_LENGTH}}$`, + "i", +); + const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig.ServerConfig; @@ -532,10 +537,15 @@ const make = Effect.gen(function* () { return yield* seededResult.error; } + // Only credentials in the issued pairing-code format are case-insensitive. + const pairingCredential = PAIRING_TOKEN_PATTERN.test(credential) + ? credential.toUpperCase() + : credential; + // Check scopes in the UPDATE so rejected requests cannot spend one-time links. const consumed = yield* pairingLinks .consumeAvailable({ - credential, + credential: pairingCredential, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, ...(input?.requestedScopes !== undefined ? { requestedScopes: input.requestedScopes } @@ -560,7 +570,7 @@ const make = Effect.gen(function* () { } const matching = yield* pairingLinks - .getByCredential({ credential }) + .getByCredential({ credential: pairingCredential }) .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); if (Option.isNone(matching)) { return yield* new UnknownBootstrapCredentialError({}); From a03e630705bc874dc8d0c297a741af3cf7e3a99f Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Thu, 29 Oct 2026 19:48:50 +0200 Subject: [PATCH 32/86] fix(web): find in thread opens from the thread title menu, so phones can reach it (#22352) Co-authored-by: maria-rcks --- apps/web/src/components/ChatView.tsx | 6 ++++++ apps/web/src/components/chat/ChatHeader.tsx | 4 ++++ .../src/components/threadActionMenu.logic.ts | 6 ++++++ apps/web/src/contextMenuFallback.ts | 7 +++++++ apps/web/src/hooks/useThreadActionMenu.test.ts | 17 ++++++++++++++++- apps/web/src/hooks/useThreadActionMenu.ts | 7 ++++++- 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 665bb2fc5f5cf..1bf6f9b0fc0e8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2636,6 +2636,11 @@ export default function ChatView(props: ChatViewProps) { : EMPTY_PROVIDER_SKILLS; const threadFindEnabled = isServerThread && serverConfig?.threadFind === true; const isThreadFindActive = useThreadFindOpen(activeThreadRef, threadFindEnabled); + const openThreadFindStable = useStableCallback((threadRef: ScopedThreadRef) => { + if (threadFindEnabled && scopedThreadKey(threadRef) === activeThreadKey) { + openThreadFind(activeThreadKey); + } + }); useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, @@ -3241,6 +3246,7 @@ export default function ChatView(props: ChatViewProps) { {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} + {...(threadFindEnabled ? { onFindInThread: openThreadFindStable } : {})} /> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ec92540efd1b0..144402d1dfe9f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, type EnvironmentId, + type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -54,6 +55,7 @@ interface ChatHeaderProps { rightPanelOpen: boolean; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; + onFindInThread?: ((threadRef: ScopedThreadRef) => void) | undefined; } function resolveRenameCommit(input: { @@ -80,6 +82,7 @@ export const ChatHeader = memo(function ChatHeader({ rightPanelOpen, onNewThreadInProject, onOpenProjectSettings, + onFindInThread, }: ChatHeaderProps) { const activeProjectName = activeProject?.isScratch ? "No project" : activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -196,6 +199,7 @@ export const ChatHeader = memo(function ChatHeader({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, onStartRename: startRename, + onFind: onFindInThread, canShareWithGuest, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 3c4626c1ea1ad..ac4ef649ed07c 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -2,6 +2,7 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; export type ThreadActionMenuId = + | "find" | "new-thread-on-branch" | "filter-by-project" | "turn-into-project" @@ -68,6 +69,7 @@ export function buildDraftActionMenuItems(options: { export interface ThreadActionMenuState { readonly canOperate: boolean; + readonly canFind?: boolean; readonly branch: string | null; readonly projectFilter: { readonly label: string; @@ -98,6 +100,7 @@ export interface ThreadActionMenuState { /** Local navigation, read markers, and copying remain available to read-only clients. */ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean { return ![ + "find", "new-thread-on-branch", "project-settings", "mark-unread", @@ -114,6 +117,9 @@ export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { const items: ReadonlyArray> = [ + ...(state.canFind + ? [{ id: "find" as const, label: "Find in thread", icon: "text-search" }] + : []), ...(state.branch ? [ { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index eece920cba664..b63fc5b3ff7a2 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -150,6 +150,13 @@ const ICON_PATHS: Record +const createMenu = (options: { readonly onFind?: (threadRef: typeof target) => void } = {}) => useThreadActionMenu({ threadRef: target, projectCwd: "/project", onStartRename: () => recordEffect("rename"), + onFind: options.onFind, canShareWithGuest: false, }); @@ -178,4 +179,18 @@ describe("thread menu permissions", () => { expect(state.effects).toEqual([]); }, ); + + it("offers find only to a header that can open it, without task permission", async () => { + createMenu().openMenu(position); + expect(state.show.mock.calls[0]![0].some((item) => item.id === "find")).toBe(false); + state.show.mockResolvedValue("find"); + const onFind = vi.fn(() => recordEffect("find")); + createMenu({ onFind }).openMenu(position); + await state.completed.promise; + const items = state.show.mock.calls[1]![0]; + expect(items[0]).toMatchObject({ id: "find", label: "Find in thread" }); + expect(items[0]?.disabled).not.toBe(true); + expect(state.effects).toEqual(["find"]); + expect(onFind).toHaveBeenCalledExactlyOnceWith(target); + }); }); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 0e25cebeef878..cddef6046cf8b 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -61,9 +61,10 @@ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; readonly projectCwd: string | null; readonly onStartRename: () => void; + readonly onFind?: ((threadRef: ScopedThreadRef) => void) | undefined; readonly canShareWithGuest: boolean; }) { - const { threadRef, projectCwd, onStartRename, canShareWithGuest } = input; + const { threadRef, projectCwd, onStartRename, onFind, canShareWithGuest } = input; const router = useRouter(); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -133,6 +134,7 @@ export function useThreadActionMenu(input: { const snoozePresets = resolveSnoozePresets(now, timestampFormat, threadRef.environmentId); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), + canFind: onFind !== undefined, branch: thread.branch ?? null, projectFilter: null, isPinned: thread.pinnedAt != null, @@ -187,6 +189,9 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "find": + onFind?.(threadRef); + return; case "project-settings": { const project = projects.find( (candidate) => From e692729e22a2fa1445b7417f7039f20e0700b0a6 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:49:15 -0500 Subject: [PATCH 33/86] fix(desktop): resolve executable commands when checking CLI shadowing (#22046) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../desktop/src/app/DesktopCliCommand.test.ts | 34 +++++++++++++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 14 +++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 3cdfdb6cfa383..7141ebde70537 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterEach, beforeEach, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -183,6 +184,39 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "skips a t3 directory or a file this user cannot execute", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const folder = path.join(home, "folder"); + const plain = path.join(home, "plain"); + const shadow = path.join(home, "shadow"); + yield* fs.makeDirectory(path.join(folder, "t3"), { recursive: true }); + yield* fs.makeDirectory(plain); + yield* fs.writeFileString(path.join(plain, "t3"), "notes\n"); + // Root can execute mode 0o601; other users must honor the owner permissions. + yield* fs.chmod(path.join(plain, "t3"), process.getuid?.() === 0 ? 0o600 : 0o601); + yield* fs.makeDirectory(shadow); + yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":"); + + const command = yield* commandIn({ home }); + expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3")); + + const error = yield* Effect.flip(command.install); + expect(error.message).toContain(path.join(shadow, "t3")); + + yield* fs.remove(shadow, { recursive: true }); + expect(yield* command.install).toMatchObject({ + onPath: true, + installedPath: path.join(home, ".local", "bin", "t3"), + }); + }).pipe(Effect.scoped), + ); + it.effect("leaves the Windows PATH alone when it cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 2ae070d638263..8a89a366814ed 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.access has no execute check. +import * as NodeFSP from "node:fs/promises"; + import type { DesktopCliCommandState } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -118,10 +121,19 @@ export const make = Effect.gen(function* () { Effect.mapError(() => fail("Could not update your PATH.")), ); + const isExecutableFile = Effect.fn("DesktopCliCommand.isExecutableFile")( + function* (target: string) { + if ((yield* fs.stat(target)).type !== "File") return false; + yield* Effect.tryPromise(() => NodeFSP.access(target, NodeFSP.constants.X_OK)); + return true; + }, + Effect.orElseSucceed(() => false), + ); + const firstOnPath = Effect.gen(function* () { for (const directory of pathEntries(process.env.PATH, ":")) { const candidate = path.join(directory, "t3"); - if (yield* exists(candidate)) return Option.some(candidate); + if (yield* isExecutableFile(candidate)) return Option.some(candidate); } return Option.none(); }); From a8e418885e203ea6014f0ca3f714fca149d4fbd0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:49:39 -0500 Subject: [PATCH 34/86] fix(server): SQLite temp files no longer block the event loop (#24194) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/persistence/Sqlite.test.ts | 45 ++++++++++++++++++++++ apps/server/src/persistence/Sqlite.ts | 3 ++ 2 files changed, 48 insertions(+) diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index 45b28834a4ad7..940d626514336 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -5,12 +5,57 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "./Sqlite.ts"; +// Linux names an open, already-deleted file " (deleted)" in /proc/self/fd. +// SQLite names its temp files "etilqs_*" and, without TMPDIR, puts them in +// /var/tmp while os.tmpdir() is /tmp, so match the name, not the directory. +const openDeletedTempFiles = () => + NodeFS.readdirSync("/proc/self/fd").flatMap((fd) => { + try { + const target = NodeFS.readlinkSync(`/proc/self/fd/${fd}`); + return target.includes("/etilqs_") && target.endsWith(" (deleted)") ? [target] : []; + } catch { + return []; + } + }); + +it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "keeps a write transaction's statement journal off the temp directory", + () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-temp-")); + const persistence = SqlitePersistence.layerFromPath( + NodePath.join(tempDir, "state.sqlite"), + ).pipe(Layer.provide(NodeServices.layer)); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE journal_probe(id INTEGER PRIMARY KEY, payload TEXT NOT NULL)`; + for (let id = 0; id < 64; id++) { + yield* sql`INSERT INTO journal_probe VALUES (${id}, ${"x".repeat(4096)})`; + } + const before = new Set(openDeletedTempFiles()); + const opened = yield* sql.withTransaction( + Effect.gen(function* () { + // A multi-row update can fail halfway, so SQLite journals every page + // it changes, here well past the 64 KiB it holds in memory by default. + yield* sql`UPDATE journal_probe SET payload = payload || 'y'`; + return openDeletedTempFiles().filter((target) => !before.has(target)); + }), + ); + assert.deepEqual(opened, []); + }).pipe( + Effect.provide(persistence), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }, +); + it.effect("drops planner statistics a database already has", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-stats-")); const dbPath = NodePath.join(tempDir, "state.sqlite"); diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 48c1ab8fc8913..2f21ea0a8a942 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -28,6 +28,9 @@ const layerSetup = Layer.effectDiscard( yield* sql`PRAGMA synchronous = NORMAL;`; // macOS fsync misses drive caches; F_FULLFSYNC flushes them at checkpoints and is ignored elsewhere. yield* sql`PRAGMA checkpoint_fullfsync = ON;`; + // Keep statement journals and sort spills off the main thread's disk path. + // Large statements and open savepoints can increase peak memory use. + yield* sql`PRAGMA temp_store = MEMORY;`; // PASSIVE checkpoints retain peak WAL size until the last connection closes. yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); From 849d2b5ac8fc9b1a891f0115f030c73d672ec646 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:50:01 -0400 Subject: [PATCH 35/86] fix(server): a branch named like a tag no longer reads as heads/ (#24258) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 17 +++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 31 ++++++++++++-------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 18709ecf0e290..b26f7d82a903b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -404,6 +404,7 @@ describe("pullCurrentBranch", () => { const driver = yield* GitVcsDriver.GitVcsDriver; const fs = yield* FileSystem.FileSystem; const { cwd, updater, initialBranch, remoteHead } = yield* cloneBehindRemote; + yield* git(cwd, ["tag", initialBranch]); yield* git(updater, ["push", "origin", "HEAD:refs/heads/other"]); const remoteName = customMapping ? "team/upstream" : "origin"; const refPrefix = customMapping @@ -2279,6 +2280,22 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("repository status", () => { + it.effect("reads the current branch by name when a tag shares it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "release"]); + yield* git(cwd, ["tag", "release"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const remoteStatus = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + assert.equal(remoteStatus.branch, "release"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const branchRange = preview.sources.find((source) => source.kind === "branch-range"); + assert.equal(branchRange?.headRef, "release"); + }), + ); + it.effect("does not open a project folder laid out as a bare repository", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f0b78aa564fb5..a7ba2b86771ac 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -514,6 +514,15 @@ function classifyGitFailure(stderr: string): GitCommandFailureReason | null { return null; } +// `--short` and `--abbrev-ref` print `heads/` when a tag shares the branch's name, so +// branch reads take the full ref and strip the prefix themselves. +function branchNameFromHeadRef(value: string): string | null { + const ref = value.trim(); + return ref.startsWith("refs/heads/") && ref.length > "refs/heads/".length + ? ref.slice("refs/heads/".length) + : null; +} + function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; @@ -1412,7 +1421,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGit( "GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -1443,11 +1452,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""), Effect.orElseSucceed(() => undefined), ); - const currentBranchOutput = currentBranchResult.stdout.trim(); const currentBranch = - currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 - ? currentBranchOutput - : null; + currentBranchResult.exitCode === 0 ? branchNameFromHeadRef(currentBranchResult.stdout) : null; return { gitCommonDir, @@ -1938,7 +1944,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], + ["rev-parse", "--symbolic-full-name", "HEAD"], { allowNonZeroExit: true }, ).pipe( Effect.catchTags({ @@ -1960,7 +1966,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetailsRemote.branch", cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], + args: ["rev-parse", "--symbolic-full-name", "HEAD"], }), detail: "Git branch lookup failed.", exitCode: branchResult.exitCode, @@ -1972,12 +1978,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchValue = yield* runGitStdout( "GitVcsDriver.statusDetailsRemote.unbornBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], ); - branch = branchValue.trim() || null; + branch = branchNameFromHeadRef(branchValue); } else { - const branchValue = branchResult.stdout.trim(); - branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + branch = branchNameFromHeadRef(branchResult.stdout); } const currentUpstreamRef = yield* readCurrentUpstreamRef(cwd); let upstreamRef = @@ -2781,9 +2786,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdout( "GitVcsDriver.pullCurrentBranch.currentBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], true, - ).pipe(Effect.map((stdout) => stdout.trim())), + ).pipe(Effect.map(branchNameFromHeadRef)), runGitStdout("GitVcsDriver.pullCurrentBranch.currentSha", cwd, ["rev-parse", "HEAD"]).pipe( Effect.map((stdout) => stdout.trim()), ), From 0ed554001a9be58c2e2080592096f784f8c7a65b Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:50:24 +0000 Subject: [PATCH 36/86] fix(server): cap generated worktree folder names (#25212) Co-authored-by: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 36 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 13 +++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index b26f7d82a903b..47b3f3a62b7e9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3488,6 +3488,42 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps generated worktree folders short without changing long branch names", () => + Effect.gen(function* () { + const pathService = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const worktreesDirectory = yield* makeTmpDir("custom-worktrees-"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const folders = new Set(); + const branches = [ + `feature/${"a".repeat(90)}/${"b".repeat(90)}/${"c".repeat(90)}`, + `feature/${"a".repeat(90)}/${"b".repeat(90)}/${"d".repeat(90)}`, + `feature/${"a".repeat(40)}/5fac`, + `feature/${"a".repeat(40)}/18828`, + `${"a".repeat(22)}😀/${"b".repeat(30)}`, + "z".repeat(40), + "z".repeat(41), + ]; + for (const branch of branches) { + const created = yield* driver.createWorktree( + { cwd, path: null, refName: initialBranch, newRefName: branch }, + { worktreesDirectory }, + ); + const folder = pathService.basename(created.worktree.path); + assert.isAtMost(folder.length, 40); + if (branch.length <= 40) assert.equal(folder, branch); + assert.equal(yield* git(created.worktree.path, ["branch", "--show-current"]), branch); + assert.include( + yield* git(cwd, ["-c", "core.quotePath=false", "worktree", "list", "--porcelain"]), + `worktree ${created.worktree.path}`, + ); + folders.add(folder); + } + assert.equal(folders.size, branches.length); + }), + ); + it.effect("resolves the submodule mode from the option, then t3.json", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a7ba2b86771ac..49149d95f58f6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3811,7 +3811,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.refName); yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.newRefName); const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); // Resolve sub/.. before taking the basename. const repoName = path.basename(path.resolve(input.cwd)); let worktreePath = input.path; @@ -3830,7 +3829,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings → Worktrees.`, }); } - worktreePath = path.join(parentDir, repoName, sanitizedBranch); + let directoryName = targetBranch.replace(/\//g, "-"); + // Leave room for files beneath the worktree on Windows, regardless of branch length. + if (directoryName.length > 40) { + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(targetBranch)) + .pipe(Effect.orDie); + // Avoid splitting a surrogate pair, which Git would list back as a different path. + const prefix = directoryName.slice(0, 23).replace(/[\uD800-\uDBFF]$/, ""); + directoryName = `${prefix}-${Hex.encode(digest).slice(0, 16)}`; + } + worktreePath = path.join(parentDir, repoName, directoryName); } if (input.newRefName) { // Kept apart from the checkout so the shared-config lock only covers the tracking write. From c6f2b7dbae472b1a6ce75bd74bc7071f4c96b789 Mon Sep 17 00:00:00 2001 From: Joseph Vidal <135430578+josephv123@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:50:47 -0400 Subject: [PATCH 37/86] perf(server): checkpoint capture scans the index listing by record instead of by byte (#21329) Co-authored-by: maria-rcks --- apps/server/src/vcs/GitVcsDriver.test.ts | 14 +++++---- apps/server/src/vcs/GitVcsDriver.ts | 36 +++++++++++++++--------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 0e3d2b5063ed2..c7a06c3bdc31f 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -682,9 +682,13 @@ it.effect.each( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect.each(["normal", "flags", "sparse"] as const)( - "checkpoint index inspection handles entries beyond the output cap (index=%s)", - (indexMode) => +it.effect.each( + (["normal", "flags", "sparse"] as const).flatMap((indexMode) => + [1, 3, 4096].map((chunkSize) => ({ indexMode, chunkSize })), + ), +)( + "checkpoint index inspection handles entries beyond the output cap (index=$indexMode, chunk=$chunkSize)", + ({ indexMode, chunkSize }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -720,8 +724,8 @@ it.effect.each(["normal", "flags", "sparse"] as const)( ...input, maxOutputBytes: 8, onStdoutChunk: (chunk) => { - for (let i = 0; i < chunk.length; i++) - input.onStdoutChunk?.(chunk.subarray(i, i + 1)); + for (let i = 0; i < chunk.length; i += chunkSize) + input.onStdoutChunk?.(chunk.subarray(i, i + chunkSize)); }, } : input, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index e3249c70882ec..acbf817462c12 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -899,7 +899,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( let specialFlags = false; let recordStart = true; let skipped = false; - let skippedRecord: number[] = []; + let skippedRecord: Uint8Array[] = []; const skippedPaths: string[] = []; yield* vcsProcess.run({ operation, @@ -909,27 +909,35 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( env: commitEnv, maxOutputBytes: 4_096, outputMode: "truncate", + // Only tags and sparse skip-worktree paths matter; scan NUL-delimited records. onStdoutChunk: (chunk) => { - for (const byte of chunk) { - if (recordStart) skipped = byte === 83; + let start = 0; + while (start < chunk.length) { + const end = chunk.indexOf(0, start); + if (recordStart) { + const tag = chunk[start]!; + skipped = tag === 83; + if ((tag >= 97 && tag <= 122) || (!sparseCheckout && tag === 83)) { + specialFlags = true; + } + } if (skipped && sparseCheckout) { - if (byte !== 0) skippedRecord.push(byte); - else { - if (skippedRecord.at(-1) !== 47) { - const name = Buffer.from(skippedRecord).subarray(2); + skippedRecord.push( + Buffer.from(chunk.subarray(start, end === -1 ? undefined : end)), + ); + if (end !== -1) { + const record = Buffer.concat(skippedRecord); + if (record.at(-1) !== 47) { + const name = record.subarray(2); if (!NodeBuffer.isUtf8(name)) specialFlags = true; else skippedPaths.push(name.toString("utf8")); } skippedRecord = []; } } - if ( - recordStart && - ((byte >= 97 && byte <= 122) || (!sparseCheckout && byte === 83)) - ) { - specialFlags = true; - } - recordStart = byte === 0; + recordStart = end !== -1; + if (!recordStart) break; + start = end + 1; } }, }); From aaba9de94f6e3f8b57ef9b605621afde791e625e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 10:51:06 -0700 Subject: [PATCH 38/86] fix(server): PR discovery sweeps skip hidden subagent threads (#26983) Co-authored-by: Claude Opus 5.5 --- .../ThreadPullRequestService.test.ts | 122 ++++++++++++++++++ .../ThreadPullRequestService.ts | 13 +- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 5627a6dc37aa9..5f380c7e0ace7 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -179,6 +179,128 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect("leaves delegated child threads to their own refresh instead of the sweep", () => + Effect.scoped( + Effect.gen(function* () { + const parent = { + ...threadShell("parent-thread"), + branch: "feature/parent", + worktreePath: "/workspace/parent", + }; + const child: OrchestrationV2ThreadShell = { + ...threadShell("child-thread"), + branch: "t3/reply-ok", + worktreePath: "/workspace/child", + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent", + }, + }; + const project: OrchestrationProjectShell = { + id: parent.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const domainEvents = yield* Queue.unbounded(); + const sweeps = yield* Queue.unbounded(); + const childLookup = yield* Deferred.make(); + const lookups: Array<{ readonly branch: string; readonly refresh: boolean }> = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.fromQueue(domainEvents), + getShellSnapshot: () => + Queue.offer(sweeps, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [parent, child], + archivedThreads: [], + }), + ), + getThreadEventSequence: () => Effect.succeed(1), + getThreadShell: (threadId) => + Effect.succeed([parent, child].find((thread) => thread.id === threadId) ?? null), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input, options) => + Effect.gen(function* () { + const refresh = options?.refresh ?? false; + lookups.push({ branch: input.branch, refresh }); + if (input.branch === child.branch && refresh) { + yield* Deferred.succeed(childLookup, undefined); + } + return null; + }), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), + }), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + FileSystem.layerNoop({}), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + + // Startup backfill sweep, then one periodic sweep. + yield* Queue.take(sweeps); + yield* service.drain; + yield* TestClock.adjust("1 minute"); + yield* Queue.take(sweeps); + yield* service.drain; + const sweepLookups = lookups.map((lookup) => lookup.branch).toSorted(); + + // The child's own turn still discovers its pull request. + yield* Queue.offer(domainEvents, { + type: "checkpoint.captured", + threadId: child.id, + } as OrchestrationV2DomainEvent); + yield* Deferred.await(childLookup); + yield* service.drain; + + expect(sweepLookups).toEqual([parent.branch, parent.branch]); + expect(lookups.slice(sweepLookups.length)).toEqual([ + { branch: child.branch, refresh: true }, + ]); + }).pipe(Effect.provide(dependencies)); + }), + ), + ); + it.effect.each([ { state: "closed", sameBranch: false }, { state: "merged", sameBranch: false }, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 7b3c09ba15b8a..0db9feac8587e 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -139,9 +139,16 @@ export const make = Effect.gen(function* () { readThreadSnapshot(request), projectStore.listShells(), ]); + // Subagent children refresh after their own runs; sweeping them costs a lookup each per minute. + const snapshotThreads = + request.threadId === null + ? threadSnapshot.threads.filter( + (thread) => thread.lineage.relationshipToParent !== "subagent", + ) + : threadSnapshot.threads; const projects = new Map(projectShells.map((project) => [project.id, project])); if (request.backfill) { - for (const thread of threadSnapshot.threads) { + for (const thread of snapshotThreads) { if ( (thread.settledOverride === "settled" || thread.settledAt !== null) && thread.branchPullRequest == null @@ -151,13 +158,13 @@ export const make = Effect.gen(function* () { } } const visibleThreadIds = new Set( - threadSnapshot.threads.filter((thread) => thread.branch !== null).map((thread) => thread.id), + snapshotThreads.filter((thread) => thread.branch !== null).map((thread) => thread.id), ); const checkedIds = request.threadId === null ? [...pendingBackfill.keys()] : [request.threadId]; for (const threadId of checkedIds) { if (!visibleThreadIds.has(threadId)) pendingBackfill.delete(threadId); } - const threads = threadSnapshot.threads.filter( + const threads = snapshotThreads.filter( (thread) => thread.archivedAt === null && ((thread.settledOverride !== "settled" && thread.settledAt === null) || From 8084e00bced3939c93c87f70301a7135c2f9d794 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:51:10 +0000 Subject: [PATCH 39/86] fix(shared): stop parsing CLI flags after -- (#25204) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../Adapters/ClaudeAdapterV2.test.ts | 15 +++++++++ packages/shared/src/cliArgs.test.ts | 33 ++++++++++++++++++- packages/shared/src/cliArgs.ts | 6 +++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ea5ee249a787f..38253cd82f1d1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -235,6 +235,21 @@ function makeClaudeTestTurnInput(input: { } describe("ClaudeAdapterV2 runtime query policy", () => { + it.each([ + "-- --thinking-display omitted", + "--thinking-display summarized -- --thinking-display omitted", + ])("does not treat operands after -- as SDK flags: %s", (launchArgs) => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "thinking-thread", + resume: false, + cwd: "/workspace", + settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs }, + }); + assert.deepEqual(options.thinking, { type: "adaptive", display: "summarized" }); + assert.equal(options.extraArgs?.["thinking-display"], "summarized"); + }); + it.each([ ["--permission-mode acceptEdits", "acceptEdits"], ["--dangerously-skip-permissions", "bypassPermissions"], diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts index e4fdfe3c2dbac..d4006e3bf4ebd 100644 --- a/packages/shared/src/cliArgs.test.ts +++ b/packages/shared/src/cliArgs.test.ts @@ -54,10 +54,41 @@ describe("parseCliArgs", () => { }); }); - it("ignores bare -- with no flag name", () => { + it("accepts a trailing -- without operands", () => { expect(parseCliArgs("--")).toEqual({ flags: {}, positionals: [] }); }); + it.each([ + { args: "--effort low -- --effort high --chrome" }, + { args: ["--effort", "low", "--", "--effort", "high", "--chrome"] }, + ])("ends option parsing at -- for $args", ({ args }) => { + expect(parseCliArgs(args)).toEqual({ + flags: { effort: "low" }, + positionals: ["--effort", "high", "--chrome"], + }); + }); + + it("preserves all operands after --, including empty strings and another --", () => { + expect(parseCliArgs(["--", "", "--", "--model=example", "-x"])).toEqual({ + flags: {}, + positionals: ["", "--", "--model=example", "-x"], + }); + }); + + it("ends option parsing with explicit boolean flags too", () => { + expect(parseCliArgs(["--chrome", "--", "--verbose"], { booleanFlags: ["chrome"] })).toEqual({ + flags: { chrome: null }, + positionals: ["--verbose"], + }); + }); + + it("keeps -- inside an equals-form option value", () => { + expect(parseCliArgs("--append-system-prompt=-- --chrome")).toEqual({ + flags: { "append-system-prompt": "--", chrome: null }, + positionals: [], + }); + }); + it("boolean flag does not consume next token as value", () => { expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( { diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts index e6e348797a907..253b7191ed83d 100644 --- a/packages/shared/src/cliArgs.ts +++ b/packages/shared/src/cliArgs.ts @@ -77,9 +77,13 @@ export function parseCliArgs( for (let i = 0; i < tokens.length; i++) { const token = tokens[i]!; + if (token === "--") { + positionals.push(...tokens.slice(i + 1)); + break; + } + if (token.startsWith("--")) { const rest = token.slice(2); - if (!rest) continue; const eqIndex = rest.indexOf("="); if (eqIndex !== -1) { From 293669d759c009ce61b47510eceb0703c4c08b0c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:51:32 +0000 Subject: [PATCH 40/86] fix(server): remove abandoned v1 database copies on startup (#25301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matěj Suchánek <16311043+immakermatty@users.noreply.github.com> --- .../persistence/initializeV2Database.test.ts | 26 ++++++++++++++++ .../src/persistence/initializeV2Database.ts | 30 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/initializeV2Database.test.ts b/apps/server/src/persistence/initializeV2Database.test.ts index 561cd72ea9f71..ec8ded1cab5ac 100644 --- a/apps/server/src/persistence/initializeV2Database.test.ts +++ b/apps/server/src/persistence/initializeV2Database.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -182,3 +183,28 @@ it.effect("starts fresh without V1 and never imports over existing V2 state", () Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), ); }); + +it.effect("removes copies left by a killed start, but not one that is still running", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-abandoned-")); + const killed = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid; + const running = `.v2-import-${process.pid}-Kq81Zx`; + const unrelated = ".v2-import-not-a-copy"; + return Effect.gen(function* () { + for (const name of [`.v2-import-${killed}-Wm3nPa`, ".v2-import-Q3xZ9a", running]) { + NodeFS.mkdirSync(NodePath.join(directory, name)); + NodeFS.writeFileSync(NodePath.join(directory, name, "snapshot.sqlite"), "partial copy"); + } + NodeFS.mkdirSync(NodePath.join(directory, unrelated)); + const destinationPath = NodePath.join(directory, "statev2.sqlite"); + NodeFS.writeFileSync(destinationPath, "existing V2 database"); + yield* initializeV2Database(destinationPath); + assert.deepEqual( + NodeFS.readdirSync(directory).sort(), + [running, unrelated, "statev2.sqlite"].sort(), + ); + assert.equal(NodeFS.readFileSync(destinationPath, "utf8"), "existing V2 database"); + }).pipe( + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), + ); +}); diff --git a/apps/server/src/persistence/initializeV2Database.ts b/apps/server/src/persistence/initializeV2Database.ts index 755907b5076f9..4f6d8d969c3ec 100644 --- a/apps/server/src/persistence/initializeV2Database.ts +++ b/apps/server/src/persistence/initializeV2Database.ts @@ -14,6 +14,33 @@ export class V2DatabaseImportError extends Schema.TaggedError { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +// Legacy names have no PID; newer copies stay while their process is alive. +const removeAbandonedCopies = Effect.fn("removeAbandonedCopies")(function* (directory: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + for (const entry of entries) { + const copy = /^\.v2-import-(?:(\d+)-)?[A-Za-z0-9]{6}$/.exec(entry); + if (copy === null || (copy[1] !== undefined && isProcessAlive(Number(copy[1])))) continue; + yield* fs.remove(path.join(directory, entry), { recursive: true }); + } +}); + /** Its copied legacy tables remain the source for lazy transcript import. */ export const initializeV2Database = Effect.fn("initializeV2Database")(function* ( destinationPath: string, @@ -22,12 +49,13 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function* const path = yield* Path.Path; const directory = path.dirname(destinationPath); const sourcePath = path.join(directory, "state.sqlite"); + yield* removeAbandonedCopies(directory).pipe(Effect.ignore({ log: true })); yield* Effect.gen(function* () { if (yield* fs.exists(destinationPath)) return; if (!(yield* fs.exists(sourcePath))) return; const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ directory, - prefix: ".v2-import-", + prefix: `.v2-import-${process.pid}-`, }); const snapshotPath = path.join(temporaryDirectory, "snapshot.sqlite"); yield* Effect.tryPromise(async () => { From 6ca95bd92a066108a04f854b858908e942e5b322 Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Thu, 29 Oct 2026 19:51:54 +0200 Subject: [PATCH 41/86] fix(web): Enter no longer skips an unanswered question (#23626) Co-authored-by: maria-rcks --- ...UserInputPanel.navigation.browser.test.tsx | 55 ++++++++++++++++--- .../chat/usePendingThreadRequests.ts | 1 + 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index 52800d94a8bca..58ad8e8c0900d 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -9,6 +9,7 @@ import { import { makeThreadRequestsResolvedAnnouncer } from "@t3tools/client-runtime/state/thread-requests"; import { useLayoutEffect, useRef, type ComponentProps } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ ...(await importOriginal()), @@ -73,20 +74,16 @@ function questionProjection(threadId: ThreadId, requestIds = ["first", "second"] } satisfies OrchestrationV2ThreadProjection; } -let requests: ReturnType; - -function PendingRequestsProbe({ - environmentId, - projection, - loading = false, -}: { +interface ProbeProps { environmentId: EnvironmentId; projection: OrchestrationV2ThreadProjection; loading?: boolean; -}) { +} + +function useProbeRequests({ environmentId, projection, loading = false }: ProbeProps) { const promptRef = useRef(""); const composerRef = useRef(null); - const state = usePendingThreadRequests({ + return usePendingThreadRequests({ environmentId, activeThreadId: projection.thread.id, serverProjection: loading ? null : projection, @@ -97,6 +94,12 @@ function PendingRequestsProbe({ composerRef, setThreadError: () => {}, }); +} + +let requests: ReturnType; + +function PendingRequestsProbe(props: ProbeProps) { + const state = useProbeRequests(props); useLayoutEffect(() => { requests = state; }); @@ -138,6 +141,40 @@ it("retains answers, selections and question position after leaving chat, includ expect(requests).toMatchObject(expected); }); +function QuestionPositionProbe(props: ProbeProps) { + const state = useProbeRequests(props); + return ( + <> + Question {state.activePendingQuestionIndex + 1} + + + + ); +} + +it("stays on an unanswered question when the composer submits", async () => { + await using screen = await render( + , + ); + const submit = screen.getByRole("button", { name: "Submit" }); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 1"); + + await userEvent.click(screen.getByRole("button", { name: "Choose Incremental" })); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 2"); +}); + it("isolates drafts by environment, thread and request and discards only resolved requests", async () => { using _timers = fakeTimers(); const environmentId = EnvironmentId.make("scope-environment"); diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 91c5bd0d6c912..8a07b86b5eece 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -473,6 +473,7 @@ export function usePendingThreadRequests(input: { } return; } + if (!activePendingProgress.canAdvance) return; setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }; const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput); From d15233544ecfa8c746e5a3d0b7e7c3a08532fc74 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:52:16 -0500 Subject: [PATCH 42/86] fix(web): keep wide characters in terminal links (#24013) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/web/src/terminal/ghostty/core.ts | 3 +- apps/web/src/terminal/ghostty/surface.test.ts | 36 +++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 23 +++++++++--- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 3bd8a89807e44..60c81478f7436 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -1067,7 +1067,8 @@ export class GhosttyTerminalCore { } } let wide = 0; - if (text.length === 0 && cells.at(-1)?.text.length) { + // A wrap spacer in the final column can follow a spacer tail. + if (text.length === 0 && (cells.at(-1)?.text.length || cells.length === cols - 1)) { this.assertSuccess( "ghostty_render_state_row_cells_get(raw)", this.runtime.call( diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 8816105599b83..5473e3c4ffa1c 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -512,6 +512,42 @@ const cell = (text: string): GhosttyCell => ({ }); describe("terminalLinkAtPositionWithRange", () => { + it.each([ + { text: "https://example.com/日本", cols: 80, end: { x: 23, y: 0 } }, + { text: "src/日本/main.ts:3:1", cols: 80, end: { x: 19, y: 0 } }, + { text: "https://example.com/🙂", cols: 80, end: { x: 21, y: 0 } }, + { text: "https://example.com/日本", cols: 23, end: { x: 1, y: 1 } }, + ])("keeps every cell of $text clickable at $cols columns", async ({ text, cols, end }) => { + const core = await GhosttyTerminalCore.create( + cols, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + try { + core.write(text + " next"); + const rows = core.snapshot().rowData; + for (let y = 0; y <= end.y; y += 1) { + const lastColumn = y === end.y ? end.x : cols - 2; + for (let x = 0; x <= lastColumn; x += 1) { + expect(terminalLinkAtPositionWithRange(rows, y, x)).toEqual({ + text, + range: { start: { x: 0, y: 0 }, end }, + }); + } + } + expect(terminalLinkAtPositionWithRange(rows, end.y, end.x + 1)).toBeNull(); + } finally { + core.dispose(); + } + }); + it("activates a wrapped quoted Windows path from its spaces and position suffix", () => { const row = (text: string, isWrapContinuation: boolean): GhosttyRow => ({ cells: Array.from(text, (character) => cell(character)), diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index cb05acdc5e03d..802d39e594075 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -2,7 +2,9 @@ import { isMacPlatform } from "../../lib/utils"; import { SELECTION_MULTI_CLICK_INTERVAL_MS } from "../../lib/selectionActions"; import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; import { + GHOSTTY_CELL_WIDE, GhosttyTerminalCore, + type GhosttyCell, type GhosttyScrollbar, type GhosttySnapshot, type GhosttyTheme, @@ -201,15 +203,23 @@ function terminalGridCellAt(options: { }; } +function terminalCellText(cell: GhosttyCell): string { + if (cell.wide === GHOSTTY_CELL_WIDE.spacerTail || cell.wide === GHOSTTY_CELL_WIDE.spacerHead) { + return ""; + } + return cell.text || " "; +} + function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { - const text = row.cells.map((cell) => cell.text || " ").join(""); + const text = row.cells.map(terminalCellText).join(""); return trimRight ? text.trimEnd() : text; } function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { let offset = 0; for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { - offset += row.cells[cellIndex]?.text.length || 1; + const cell = row.cells[cellIndex]; + offset += cell ? terminalCellText(cell).length : 1; } return offset; } @@ -265,7 +275,8 @@ export function terminalLinkAtPositionWithRange( const lastSegment = wrappedLine.segments.at(-1); const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; - const offset = segment.startIndex + terminalColumnOffset(row, column); + const hitColumn = row.cells[column]?.wide === GHOSTTY_CELL_WIDE.spacerTail ? column - 1 : column; + const offset = segment.startIndex + terminalColumnOffset(row, hitColumn); for (const match of extractTerminalLinks(wrappedLine.text)) { if (offset >= match.start && offset < match.end) { if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; @@ -278,6 +289,7 @@ export function terminalLinkAtPositionWithRange( const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined; const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined; if (!startSegment || !endSegment || !startRow || !endRow) return null; + const endColumn = terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex); return { text: match.text, range: { @@ -286,7 +298,10 @@ export function terminalLinkAtPositionWithRange( y: startSegment.bufferLineNumber - 1, }, end: { - x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + x: + endRow.cells[endColumn + 1]?.wide === GHOSTTY_CELL_WIDE.spacerTail + ? endColumn + 1 + : endColumn, y: endSegment.bufferLineNumber - 1, }, }, From a49a3f771b4566d79e93a85afc3480a60c33bfed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 10:52:24 -0700 Subject: [PATCH 43/86] fix(server): session stops no longer trigger storage sweeps unless a deleted worktree waits (#26038) Co-authored-by: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 367 ++++++++++++++++++++++++- apps/server/src/storageCleanup.ts | 77 ++++-- 2 files changed, 412 insertions(+), 32 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 9cbf0f092f7bd..5f945abd1858c 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -16,6 +16,8 @@ import { DEFAULT_SERVER_SETTINGS, type WorktreeKeepWhen, type StorageCleanupSettings, + type TerminalMetadataStreamEvent, + type TerminalSummary, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "./config.ts"; @@ -32,8 +34,11 @@ import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAd import { ServerActivation } from "./serverActivation.ts"; import { describe, expect, it } from "@effect/vitest"; import { + EventId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + ProviderSessionId, RunId, RuntimeRequestId, ThreadId, @@ -268,8 +273,11 @@ const cleanupFixture = ( let editBeforeRemoval = false; let openSessionCwd: string | null = null; let sessionLive = true; + let onLiveCheck: Effect.Effect = Effect.void; + let outboxStatus: string | null = null; let fullStatusCalls = 0; let snapshotReads = 0; + let terminalListener: ((event: TerminalMetadataStreamEvent) => Effect.Effect) | undefined; let events: Stream.Stream = Stream.empty; const activated = yield* Deferred.make(); const context = yield* Layer.build(StorageCleanup.layer).pipe( @@ -335,7 +343,8 @@ const cleanupFixture = ( Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, { - isLive: () => Effect.sync(() => sessionLive), + isLive: () => + Effect.sync(() => sessionLive).pipe(Effect.tap(() => Effect.suspend(() => onLiveCheck))), } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]), Effect.provideService(Orchestrator.OrchestratorV2, { streamDomainEvents: Stream.suspend(() => events), @@ -359,29 +368,39 @@ const cleanupFixture = ( }), }, ] - : strings.join("").includes("deleted_at IS NOT NULL") - ? deletedThreads.map((thread) => ({ - payload_json: JSON.stringify(thread), - workspaceRoot: repo, - })) - : strings.join("").includes('AS "worktreePath"') - ? [ - ...threads.flatMap((thread) => thread.worktreePath ?? []), - ...storedWorktreePaths, - ].map((worktreePath) => ({ worktreePath })) - : [], + : outboxStatus !== null && strings.join("").includes("orchestration_v2_effect_outbox") + ? [{ status: outboxStatus }] + : strings.join("").includes("deleted_at IS NOT NULL") + ? deletedThreads.map((thread) => ({ + payload_json: JSON.stringify(thread), + workspaceRoot: repo, + })) + : strings.join("").includes('AS "worktreePath"') + ? [ + ...threads.flatMap((thread) => thread.worktreePath ?? []), + ...storedWorktreePaths, + ].map((worktreePath) => ({ worktreePath })) + : [], )) as unknown as SqlClient.SqlClient), Effect.provideService(GitManager.GitManager, { invalidateStatus: () => Effect.void, } as unknown as GitManager.GitManager["Service"]), Effect.provideService(TerminalManager.TerminalManager, { - subscribeMetadata: () => Effect.succeed(() => {}), + subscribeMetadata: ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + ) => + Effect.sync(() => { + terminalListener = listener; + return () => {}; + }), } as unknown as TerminalManager.TerminalManager["Service"]), Effect.provideService(ServerActivation, Deferred.await(activated)), ); return { service: Context.get(context, StorageCleanup.StorageCleanup), repo, + setTerminals: (terminals: TerminalSummary[]) => + terminalListener?.({ type: "snapshot", terminals }) ?? Effect.void, editBeforeRemoval: () => { editBeforeRemoval = true; }, @@ -421,6 +440,12 @@ const cleanupFixture = ( setThreads: (next: typeof threads) => { threads = next; }, + setOnLiveCheck: (hook: Effect.Effect) => { + onLiveCheck = hook; + }, + setOutboxStatus: (status: string | null) => { + outboxStatus = status; + }, setDeletedThreads: (next: OrchestrationV2ThreadShell[]) => { deletedThreads = next; }, @@ -925,6 +950,322 @@ describe("deleted thread worktrees", () => { }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); + // Releases one stopped-session event after the startup sweep and returns the snapshot reads it caused. + const stopSessionAfterStartupSweep = ( + fixture: Effect.Success>, + threadId: ThreadId, + beforeStop: Effect.Effect = Effect.void, + cwd = fixture.worktree, + ) => + Effect.gen(function* () { + const startupSwept = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + const stopped: OrchestrationV2DomainEvent = { + id: EventId.make("event:session-stopped"), + type: "provider-session.updated", + threadId, + occurredAt: at(0), + payload: { + id: ProviderSessionId.make("session-1"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + cwd, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: at(-DAY_MS), + updatedAt: at(0), + lastError: null, + }, + }; + yield* fixture.activate( + Stream.fromEffect(Deferred.await(startupSwept)).pipe( + Stream.flatMap(() => Stream.make(stopped)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(delivered, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runDrain, + ); + yield* fixture.service.drain; + yield* beforeStop; + const readsBefore = fixture.snapshotReads(); + yield* Deferred.succeed(startupSwept, undefined); + yield* Deferred.await(delivered); + yield* fixture.service.drain; + return fixture.snapshotReads() - readsBefore; + }); + + it.effect("does not sweep when a live thread's session stops and no deleted worktree waits", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + const reads = yield* stopSessionAfterStartupSweep(fixture, ThreadId.make("thread-1")); + expect(reads, "a live thread's stopped session re-read every thread").toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("removes a deleted thread's worktree once its session stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + fixture.setOpenSessionCwd(fixture.worktree); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.sync(() => fixture.setSessionLive(false)), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("removes a deleted thread's worktree once a live thread's session in it stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + const child = subagent(owner); + fixture.setDeletedThreads([owner]); + fixture.setThreads([child]); + fixture.setOpenSessionCwd(fixture.worktree); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + child.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Provider session is still open", + }); + fixture.setSessionLive(false); + }), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "removes a deleted thread's worktree once its terminal closes and its session stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + yield* fixture.setTerminals([ + { + threadId: owner.id, + terminalId: "terminal-1", + cwd: fixture.worktree, + worktreePath: fixture.worktree, + status: "running", + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal", + updatedAt: DateTime.formatIso(at(0)), + }, + ]); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Open terminal", + }); + yield* fixture.setTerminals([]); + }), + fixture.repo, + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "retries a deleted thread's worktree kept for another reason when a session in it stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "agent edit\n"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Has uncommitted changes (1 file)", + }); + yield* fixture.fs + .writeFileString(`${fixture.worktree}/tracked.txt`, "original\n") + .pipe(Effect.orDie); + }), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("does not sweep for a kept deleted worktree when a session elsewhere stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + fixture.setDeletedThreads([parent(fixture.worktree)]); + fixture.setThreads([]); + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "agent edit\n"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + ThreadId.make("thread-elsewhere"), + Effect.void, + fixture.repo, + ); + expect(reads).toBe(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "does not sweep for a deleted thread whose cleanup effect failed when a session elsewhere stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + fixture.setOutboxStatus("failed"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + ThreadId.make("thread-elsewhere"), + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Thread deletion is still pending", + }); + }), + fixture.repo, + ); + expect(reads).toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "removes a deleted thread's worktree when its session stops during the deletion's sweep", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setThreads([]); + const startupSwept = yield* Deferred.make(); + const sessionChecked = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + const deleted = { + id: EventId.make("event:thread-deleted"), + type: "thread.deleted", + threadId: owner.id, + occurredAt: at(0), + payload: owner, + } as unknown as OrchestrationV2DomainEvent; + const stopped: OrchestrationV2DomainEvent = { + id: EventId.make("event:session-stopped"), + type: "provider-session.updated", + threadId: owner.id, + occurredAt: at(0), + payload: { + id: ProviderSessionId.make("session-1"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + cwd: fixture.worktree, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: at(-DAY_MS), + updatedAt: at(0), + lastError: null, + }, + }; + yield* fixture.activate( + Stream.fromEffect(Deferred.await(startupSwept)).pipe( + Stream.flatMap(() => Stream.make(deleted)), + Stream.concat(Stream.fromEffect(Deferred.await(sessionChecked)).pipe(Stream.drain)), + Stream.concat(Stream.make(stopped)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(delivered, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runDrain, + ); + yield* fixture.service.drain; + fixture.setDeletedThreads([owner]); + fixture.setOpenSessionCwd(fixture.worktree); + // The deletion's sweep sees the session live; it stops before that sweep publishes its result. + fixture.setOnLiveCheck( + Effect.gen(function* () { + fixture.setOnLiveCheck(Effect.void); + yield* Deferred.succeed(sessionChecked, undefined); + yield* Deferred.await(delivered); + fixture.setSessionLive(false); + }), + ); + yield* Deferred.succeed(startupSwept, undefined); + yield* Deferred.await(delivered); + yield* fixture.service.drain; + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + it.effect("waits out the settle grace of a subagent that just settled", () => Effect.gen(function* () { yield* TestClock.setTime(NOW_MS); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 9bf040b0cd17c..48154835e7bb4 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -705,7 +705,10 @@ const make = Effect.gen(function* () { now: number, entries: StorageCleanupReportEntry[], ) { - if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return; + if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) + return { waiting: false, kept: [] }; + let waiting = false; + const kept: string[] = []; const roots: Array = []; for (const directory of managedWorktreesDirectories( serverSettings, @@ -779,7 +782,15 @@ const make = Effect.gen(function* () { bytes: null, files: null, }; - const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) }); + const keep = (reason: string) => { + if (deleted && settings.worktreeOnDelete) kept.push(worktreePath); + entries.push({ ...entry, reason: sentence(reason) }); + }; + // Reasons a provider session stop or finished deletion work can clear. + const waitForStop = (reason: string) => { + if (deleted && settings.worktreeOnDelete) waiting = true; + return reason; + }; yield* Effect.gen(function* () { if (!(yield* fs.exists(worktreePath))) return; if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); @@ -790,10 +801,10 @@ const make = Effect.gen(function* () { thread.branch === null || !group.every((member) => storageCleanupThreadIdle(member, now)) ) - return keep("thread is running or has pending work"); + return keep(waitForStop("thread is running or has pending work")); if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); - if (hasTerminal(worktreePath)) return keep("open terminal"); + if (hasTerminal(worktreePath)) return keep(waitForStop("open terminal")); // Compare canonical roots while allowing symlinked parents, never symlinked worktree directories. const realPath = yield* fs.realPath(worktreePath); const realParent = yield* fs.realPath(path.dirname(worktreePath)); @@ -817,7 +828,7 @@ const make = Effect.gen(function* () { SETTLE_SCRIPT_GRACE_MS, ) ) - return keep("settled moments ago"); + return keep(waitForStop("settled moments ago")); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); const old = !deleted && @@ -924,7 +935,7 @@ const make = Effect.gen(function* () { const latest = (yield* projections.getThreadWorktreePaths({ includeArchived: true, })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); - if (hasTerminal(worktreePath)) return "open terminal"; + if (hasTerminal(worktreePath)) return waitForStop("open terminal"); if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -932,10 +943,13 @@ const make = Effect.gen(function* () { ) return "thread or cleanup settings changed since check"; // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout. - const pendingCleanup = yield* sql` - SELECT 1 FROM orchestration_v2_effect_outbox - WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 + const pendingCleanup = yield* sql<{ status: string }>` + SELECT status FROM orchestration_v2_effect_outbox + WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') `.pipe(NodeSqliteClient.readOnly); + // Failed effects never retry, so only running work can finish and free the path. + if (pendingCleanup.some((row) => row.status !== "failed")) + return waitForStop("thread deletion is still pending"); if (pendingCleanup.length > 0) return "thread deletion is still pending"; } const latestShells = yield* Effect.forEach(latest, (entry) => @@ -960,7 +974,7 @@ const make = Effect.gen(function* () { ); }) ) - return "thread activity or shared worktree changed since check"; + return waitForStop("thread activity or shared worktree changed since check"); // Sessions can outlive their run and can be shared across app threads. const sessionRows = yield* sql<{ payload_json: string }>` SELECT payload_json FROM orchestration_v2_projection_provider_sessions @@ -976,7 +990,7 @@ const make = Effect.gen(function* () { (cwd === worktreePath || inside(worktreePath, cwd)) && (yield* providerSessions.isLive(session.id)) ) - return "provider session is still open"; + return waitForStop("provider session is still open"); } return null; }); @@ -1036,6 +1050,7 @@ const make = Effect.gen(function* () { outcome: "failed", reason: cleanupFailureReason(error), }); + if (deleted && settings.worktreeOnDelete) kept.push(worktreePath); yield* Effect.logWarning("storage cleanup failed for worktree", { threadId: thread.id, error, @@ -1049,6 +1064,7 @@ const make = Effect.gen(function* () { yield* projectStore.list({ includeDeleted: true }), entries, ); + return { waiting, kept }; }); const cleanFiles = Effect.fn("StorageCleanup.cleanFiles")(function* ( @@ -1090,14 +1106,21 @@ const make = Effect.gen(function* () { }); const reportRef = yield* SubscriptionRef.make(null); + // Only deleted threads' worktrees wait on session stops: any stop while one waits, or a stop inside one kept for another reason. + // A deletion stays pending until a sweep that started after it finishes. + let deletedWorktreeWaits = true; + let keptDeletedWorktrees: ReadonlyArray = []; + let deletions = 0; + let sweptDeletions = 0; const sweep = Effect.fn("StorageCleanup.sweep")(function* ( trigger: StorageCleanupReport["trigger"], ) { + const deletionsAtStart = deletions; const now = yield* Clock.currentTimeMillis; const serverSettings = yield* settingsService.getSettings; const settings = serverSettings.storageCleanup; const entries: StorageCleanupReportEntry[] = []; - yield* cleanWorktrees(serverSettings, now, entries).pipe( + const deletedWorktrees = yield* cleanWorktrees(serverSettings, now, entries).pipe( Effect.catch((error) => Effect.gen(function* () { entries.push({ @@ -1112,9 +1135,13 @@ const make = Effect.gen(function* () { files: null, }); yield* Effect.logWarning("worktree cleanup failed", { error }); + return { waiting: true, kept: [] }; }), ), ); + deletedWorktreeWaits = deletedWorktrees.waiting; + keptDeletedWorktrees = deletedWorktrees.kept; + sweptDeletions = deletionsAtStart; for (const category of [ { kind: "browser-artifacts" as const, @@ -1255,14 +1282,26 @@ const make = Effect.gen(function* () { }), ); yield* forkParked( - Stream.runForEach(events, (event) => - (event.type === "thread.deleted" || - (event.type === "provider-session.updated" && - (event.payload.status === "stopped" || event.payload.status === "error"))) && - anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) + Stream.runForEach(events, (event) => { + if (event.type === "thread.deleted") deletions++; + else if ( + event.type !== "provider-session.updated" || + (event.payload.status !== "stopped" && event.payload.status !== "error") + ) + return Effect.void; + else if (!deletedWorktreeWaits && deletions === sweptDeletions) { + const cwd = pathKey(event.payload.cwd); + if ( + !keptDeletedWorktrees.some( + (worktreePath) => pathKey(worktreePath) === cwd || inside(worktreePath, cwd), + ) + ) + return Effect.void; + } + return anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) ? requestSweep - : Effect.void, - ).pipe( + : Effect.void; + }).pipe( Effect.catchCause((cause) => Effect.logWarning("Storage cleanup event stream failed", { cause }), ), From 443edd553fce0f00de480311583579fcc91c529c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:52:42 -0500 Subject: [PATCH 44/86] fix(provider-core): subagent threads no longer inherit the parent's pin (#21271) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../SubagentProjection.test.ts | 61 ++++++++++++++++++- .../src/server/subagentProjection.ts | 2 + 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index d3506649f6a62..95abcbaf5c1a8 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { makeSubagentChildThread, @@ -26,7 +27,9 @@ import { endRunlessRootTurns, } from "@t3tools/provider-core/server/subagentProjection"; -import { emptyProjection } from "./ProjectionStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import { resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent"); const childThreadId = ThreadId.make("thread:subagent-awake-child"); @@ -117,8 +120,62 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); +it.effect.each([ + ["sql", ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory))], +] as const)( + "%s: a pinned parent's subagent child is born unpinned and settles once idle", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const parentThread = { ...makeParentThread(), pinnedAt: parentCreatedAt, pinOrderKey: "a0" }; + const childThread = makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: NodeId.make("node:subagent-parent"), + activeProviderThreadId: null, + providerInstanceId: childProviderInstanceId, + modelSelection: childModelSelection, + title: "Child", + now: childCreatedAt, + createdBy: "agent", + creationSource: "provider", + }); + + assert.equal(parentThread.pinnedAt, parentCreatedAt); + assert.equal(parentThread.pinOrderKey, "a0"); + assert.isNull(childThread.pinnedAt); + assert.isNull(childThread.pinOrderKey); + + for (const thread of [parentThread, childThread]) { + yield* store.apply({ + id: EventId.make(`event:${thread.id}`), + type: "thread.created", + threadId: thread.id, + occurredAt: thread.createdAt, + payload: thread, + }); + } + const savedParent = yield* store.getThread(parentThreadId); + assert.deepEqual(savedParent.pinnedAt, parentCreatedAt); + assert.equal(savedParent.pinOrderKey, "a0"); + const candidates = yield* store.getSettlementCandidates(); + assert.deepEqual( + candidates.map((candidate) => candidate.id), + [childThreadId], + ); + const settledAt = resolveAutoSettlementAt({ + thread: candidates[0]!, + pullRequest: null, + nowMs: DateTime.toEpochMillis(DateTime.add(childCreatedAt, { days: 4 })), + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + }); + assert.equal(settledAt && DateTime.formatIso(settledAt), DateTime.formatIso(childCreatedAt)); + }).pipe(Effect.provide(testLayer)), +); + function taskFixture() { - const projection = emptyProjection({ + const projection = ProjectionStore.emptyProjection({ type: "thread.created", id: EventId.make("task-fixture"), threadId: parentThreadId, diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index f83a0b376233b..be8c4bf98aa54 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -82,6 +82,8 @@ export function makeSubagentChildThread(input: { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, // A guest link belongs to the thread it was made for. guestShare: null, lastVisitedAt: null, From 1b3e330c98f8f063911b730b1befb77c13c7d791 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:53:05 -0500 Subject: [PATCH 45/86] fix(device): report failed Android permission changes (#22358) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/server/src/device/DeviceActions.test.ts | 54 ++++++++++++++++++++ apps/server/src/device/DeviceActions.ts | 11 ++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 1a8cab7f29837..90f46a4b66d33 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -98,6 +98,60 @@ describe("runDeviceAction", () => { expect(calls.at(-1)?.args.at(-1)).toBe(expected); }), ); + + it.effect.each([ + ["camera", "grant", "android.permission.CAMERA"], + ["photos", "revoke", "android.permission.READ_MEDIA_IMAGES"], + ] as const)( + "reports an Android %s %s that no pm command applied", + ([permission, decision, firstPermission]) => + Effect.gen(function* () { + const { ready } = makeReady((call) => ({ + code: 1, + stderr: `Unknown package: ${call.args.at(-1)}`, + })); + const error = yield* Effect.flip( + runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.missing", + permission, + decision, + }), + ); + expect(error._tag).toBe("DeviceOperationError"); + expect(error.operation).toBe("permission"); + expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({ + stderr: `Unknown package: ${firstPermission}`, + }); + }), + ); + + it.effect.each([ + [ + "photos", + ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"], + 1, + ], + ["contacts", ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], 0], + ] as const)( + "keeps a partially applied %s permission change", + ([permission, permissions, success]) => + Effect.gen(function* () { + const { ready, calls } = makeReady((call) => ({ + code: call.args.at(-1) === permissions[success] ? 0 : 1, + })); + const result = yield* runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.app", + permission, + decision: "grant", + }); + expect(result).toBeUndefined(); + expect(calls.map((call) => call.args.at(-1))).toEqual(permissions); + }), + ); }); describe("readDeviceDetail", () => { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 905a23d5cc726..6b734b6b9b484 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -11,6 +11,7 @@ import { type DeviceTextSize, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { DeviceHostReady } from "./DeviceHost.ts"; @@ -432,9 +433,13 @@ const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( }); } const verb = input.decision === "grant" ? "grant" : "revoke"; - for (const permission of permissions) { - yield* shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.ignore); - } + // `pm` rejects a permission the app or system image does not declare, and Photos lists both + // the Android 13 and the older storage permission, so fail only when none of them applied. + const results = yield* Effect.forEach(permissions, (permission) => + shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.result), + ); + const failure = results.find(Result.isFailure); + if (failure && !results.some(Result.isSuccess)) return yield* failure.failure; return; } case "openUrl": From 0d51375ef765636549410286e42b391a9e2b7ca2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 10:53:18 -0700 Subject: [PATCH 46/86] fix(server): thread lists load beyond SQLite variable limit (#25696) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/ProjectionStore.test.ts | 64 +++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 14 ++-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 2af87ecf4feb7..17b8bb818864a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1374,6 +1374,70 @@ it.effect("lists only active subagent IDs without loading shell data", () => }).pipe(Effect.provide(layerTest)), ); +// regression: #25621 SQLite rejects statements binding more than 32,766 variables +it.effect("lists every thread in the shell snapshot past SQLite's bound variable limit", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const templateId = ThreadId.make("thread:many:00000"); + yield* store.apply({ + id: EventId.make(`event:${templateId}:created`), + type: "thread.created", + threadId: templateId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: templateId, + projectId: ProjectId.make("project:many"), + title: "Many", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: templateId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + const threadCount = 32_767; + yield* sql` + CREATE TEMP TABLE many_threads AS + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${threadCount - 1}) + SELECT t.*, n.i AS clone_index FROM orchestration_v2_projection_threads t, n + WHERE t.thread_id = ${templateId} + `; + yield* sql` + UPDATE many_threads SET + thread_id = printf('thread:many:%05d', clone_index), + payload_json = json_set( + payload_json, + '$.id', printf('thread:many:%05d', clone_index), + '$.lineage.rootThreadId', printf('thread:many:%05d', clone_index) + ) + `; + yield* sql`ALTER TABLE many_threads DROP COLUMN clone_index`; + yield* sql`INSERT INTO orchestration_v2_projection_threads SELECT * FROM many_threads`; + + const [unscoped, active] = yield* Effect.all([ + store.getShellSnapshot(), + store.getShellSnapshot({ location: "active" }), + ]); + assert.strictEqual(unscoped.threads.length, threadCount); + assert.strictEqual(active.threads.length, threadCount); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("ProjectionStoreV2", (it) => { // regression: #21969 keep corrupt plan JSON in the typed read error it.effect("returns a typed read error for malformed stored plan JSON", () => diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2a21bfae4bd32..287a30a9d904e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5940,7 +5940,7 @@ export const layer: Layer.Layer = worktreePath === undefined ? sql`` : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` - }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN ${sql.in(threadIds)}`} + }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -5953,14 +5953,14 @@ export const layer: Layer.Layer = : sql` SELECT thread_id, run_id, ordinal FROM orchestration_v2_projection_runs - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; const selectShellRunItemCounts = (threadIds: ReadonlyArray) => sql` SELECT thread_id, run_id, COUNT(*) AS item_count FROM orchestration_v2_projection_turn_items - WHERE run_id IS NOT NULL AND thread_id IN ${sql.in(threadIds)} + WHERE run_id IS NOT NULL AND thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY thread_id, run_id `; @@ -5975,7 +5975,7 @@ export const layer: Layer.Layer = FROM ( SELECT thread_id, run_id, COUNT(*) AS item_count FROM orchestration_v2_projection_turn_items - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY thread_id, run_id ) counts LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_id @@ -5992,7 +5992,7 @@ export const layer: Layer.Layer = : sql` SELECT thread_id, payload_json FROM orchestration_v2_projection_provider_threads - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; const selectShellPendingTurnItemRows = ( @@ -6008,7 +6008,7 @@ export const layer: Layer.Layer = WHERE i.type IN ('command_execution', 'dynamic_tool', 'subagent') AND i.status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') AND (i.run_id IS NULL OR r.status <> 'rolled_back') - ${threadIds === undefined ? sql`` : sql`AND i.thread_id IN ${sql.in(threadIds)}`} + ${threadIds === undefined ? sql`` : sql`AND i.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ${ activeRunIds.length === 0 ? sql`` @@ -6017,7 +6017,7 @@ export const layer: Layer.Layer = UNION ALL SELECT i.thread_id, i.payload_json, NULL AS owning_provider_instance_id FROM orchestration_v2_projection_turn_items i - WHERE i.run_id IN ${sql.in(activeRunIds)} + WHERE i.run_id IN (SELECT value FROM json_each(${encodeIdList(activeRunIds)})) AND i.type IN ('file_change', 'file_search', 'web_search', 'compaction') AND i.status IN ('running', 'waiting') ` From 1e3e3a8dfd87cfc085828a7ae4de995b7addcb4f Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:53:38 +0000 Subject: [PATCH 47/86] fix(server): allow large git status metadata (#25207) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 39 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 29 ++++++++------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 47b3f3a62b7e9..cbc3efcc3b4e7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1003,6 +1003,45 @@ describe("rejected git refs", () => { }); it.layer(layerTest)("GitVcsDriver core integration", (it) => { + it.effect("keeps large change sets available for status and commit preparation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + const files = Array.from( + { length: 6_000 }, + (_, index) => `file-${String(index).padStart(5, "0")}-${"x".repeat(160)}.txt`, + ); + yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "before\n"), { + concurrency: 16, + discard: true, + }); + yield* git(cwd, ["add", "."]); + const unborn = yield* driver.statusDetailsLocal(cwd); + assert.lengthOf(unborn.workingTree.files, files.length); + assert.equal(unborn.workingTree.insertions, files.length); + const initial = yield* driver.commit(cwd, "initial", ""); + assert.match(initial.commitSha, /^[a-f0-9]{40}$/); + assert.isFalse((yield* driver.statusDetailsLocal(cwd)).hasWorkingTreeChanges); + yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "after\n"), { + concurrency: 16, + discard: true, + }); + const status = yield* driver.statusDetailsLocal(cwd); + assert.isTrue(status.hasWorkingTreeChanges); + assert.lengthOf(status.workingTree.files, files.length); + assert.equal(status.workingTree.insertions, files.length); + assert.equal(status.workingTree.deletions, files.length); + const context = yield* driver.prepareCommitContext(cwd, undefined, { includePatch: false }); + assert.isNotNull(context); + assert.isAbove(context!.stagedSummary.length, 1_000_000); + assert.lengthOf(context!.stagedSummary.split("\n"), files.length); + }), + ); describe("structured errors", () => { it.effect("does not retain git arguments or stderr in command failures", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 49149d95f58f6..c486c6744cb5f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2097,6 +2097,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusArgs, { allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, }, ).pipe( Effect.catchTags({ @@ -2137,24 +2138,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.statusDetails.numstat", cwd, ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], - { allowNonZeroExit: true }, + { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe( Effect.flatMap((result) => { if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout)); if (isUnbornHeadStderr(result.stderr)) { return Effect.map( Effect.all([ - runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ - "diff-files", - "--numstat", - "-z", - ]), - runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ - "diff", - "--cached", - "--numstat", - "-z", - ]), + runGitStdoutWithOptions( + "GitVcsDriver.statusDetails.numstat.unborn", + cwd, + ["diff-files", "--numstat", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.statusDetails.numstat.unborn.staged", + cwd, + ["diff", "--cached", "--numstat", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), ]), ([unstagedStdout, stagedStdout]) => [ ...parseReviewNumstat(stagedStdout), @@ -2444,7 +2446,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.prepareCommitContext.stagedSummary", cwd, ["diff", "--cached", "--name-status"], - { env }, + { env, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe(Effect.map((stdout) => stdout.trim())); if (stagedSummary.length === 0) { return null; @@ -2508,6 +2510,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); From 781cc55c2d182566fbf650abc08d0ad6e67a7fbf Mon Sep 17 00:00:00 2001 From: luke <97411838+luke2x@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:54:01 +0000 Subject: [PATCH 48/86] perf(mobile): flatten large file trees without copying descendants (#21276) Co-authored-by: maria-rcks --- .../src/features/files/fileTree.test.ts | 61 +++++++++++++++++++ apps/mobile/src/features/files/fileTree.ts | 8 +-- 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/features/files/fileTree.test.ts diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts new file mode 100644 index 0000000000000..d978333d2732f --- /dev/null +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vite-plus/test"; + +import { buildFileTree, flattenFileTree } from "./fileTree"; + +it("flattens a wide expanded directory without exceeding argument limits", () => { + const children = Array.from({ length: 200_000 }, (_, index) => ({ + path: `root/file-${index}`, + name: `file-${index}`, + kind: "file" as const, + children: [], + })); + const nodes = [{ path: "root", name: "root", kind: "directory" as const, children }]; + const rows = flattenFileTree({ nodes, expanded: new Set(["root"]) }); + expect(rows).toHaveLength(children.length + 1); + expect(rows[0]).toEqual({ node: nodes[0], depth: 0 }); + const middleChildIndex = Math.floor(children.length / 2); + expect(rows[middleChildIndex + 1]).toEqual({ + node: children[middleChildIndex], + depth: 1, + }); + expect(rows.at(-1)).toEqual({ node: children.at(-1), depth: 1 }); +}); + +it("prunes unmatched subtrees without losing adjacent rows or their depths", () => { + const nodes = buildFileTree([ + { kind: "file", path: "root/a/hidden.txt" }, + { kind: "file", path: "root/b/match.txt" }, + { kind: "file", path: "root/b/other.txt" }, + { kind: "file", path: "root/c/hidden.txt" }, + { kind: "file", path: "tail.txt" }, + ]); + const expanded = new Set(["root", "root/a", "root/b", "root/c"]); + const rows = (searchResultPaths: ReadonlySet | null) => + flattenFileTree({ nodes, expanded, searchResultPaths }).map( + ({ node, depth }) => `${depth}:${node.path}`, + ); + + expect(rows(new Set(["root/b/match.txt", "tail.txt"]))).toEqual([ + "0:root", + "1:root/b", + "2:root/b/match.txt", + "0:tail.txt", + ]); + expect(rows(new Set(["root/b"]))).toEqual(["0:root", "1:root/b"]); + expect(rows(new Set())).toEqual([]); + expect(rows(null)).toEqual([ + "0:root", + "1:root/a", + "2:root/a/hidden.txt", + "1:root/b", + "2:root/b/match.txt", + "2:root/b/other.txt", + "1:root/c", + "2:root/c/hidden.txt", + "0:tail.txt", + ]); + expect(flattenFileTree({ nodes, expanded: new Set() }).map(({ node }) => node.path)).toEqual([ + "root", + "tail.txt", + ]); +}); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 4e4b4b77171dd..22e8e9bd5bd8d 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -100,11 +100,12 @@ function flattenNode( const isSearching = searchResultPaths !== null; const matches = searchResultPaths?.has(node.path) ?? false; let descendantMatches = false; - const childOutput: VisibleFileTreeNode[] = []; + const start = output.length; + output.push({ node, depth }); if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) { for (const child of node.children) { - if (flattenNode(childOutput, child, depth + 1, expanded, searchResultPaths)) { + if (flattenNode(output, child, depth + 1, expanded, searchResultPaths)) { descendantMatches = true; } } @@ -112,11 +113,10 @@ function flattenNode( const visible = !isSearching || matches || descendantMatches; if (!visible) { + output.length = start; return false; } - output.push({ node, depth }); - output.push(...childOutput); return matches || descendantMatches; } From 80ae9b53015bc3df7f1b5d1c2bc13602ee88625b Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:54:24 -0500 Subject: [PATCH 49/86] fix(web): show theme conflicts even when another import fails (#23223) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../ThemeImportDialog.browser.test.tsx | 58 +++++++++++++++++++ .../components/settings/ThemeImportDialog.tsx | 11 ++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx index 3a6478434c940..2ee0085bbb1c6 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx @@ -3,6 +3,14 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { render } from "../../../test/render"; +import { + CUSTOM_THEMES_STORAGE_KEY, + getCustomThemes, + installCustomTheme, + invalidateCustomThemes, + parseThemeFile, + THEME_FILE_VERSION, +} from "../../themePalette"; import type { OpenVsxThemeExtension } from "../../openVsxThemes"; import { ThemeImportDialog } from "./ThemeImportDialog"; @@ -54,3 +62,53 @@ describe("ThemeImportDialog", () => { ); }); }); + +const theme = (name: string, accent = "#3b82f6") => ({ + version: THEME_FILE_VERSION, + name, + appearance: "dark", + colors: { accent }, +}); + +const themeFile = (name: string, accent?: string) => + new File([JSON.stringify(theme(name, accent))], `${name}.json`, { type: "application/json" }); + +it.each(["Update existing", "Keep both"])( + "keeps batch failures visible after %s", + async (choice) => { + using _themes = { + [Symbol.dispose]() { + window.localStorage.removeItem(CUSTOM_THEMES_STORAGE_KEY); + invalidateCustomThemes(); + }, + }; + installCustomTheme(parseThemeFile(theme("Dusk"))); + const onOpenChange = vi.fn(); + await using _screen = await render( + true} + onImportedMany={() => {}} + />, + ); + const input = document.querySelector('input[type="file"]')!; + await userEvent.upload(input, [ + themeFile("Dawn"), + themeFile("Dusk", "#f97316"), + new File(["{"], "broken.json", { type: "application/json" }), + ]); + + await expect.element(page.getByText("Already installed")).toBeVisible(); + await expect.element(page.getByText(/^broken\.json:/)).toBeVisible(); + expect(getCustomThemes().map((theme) => theme.label)).toContain("Dawn"); + await page.getByRole("button", { name: choice }).click(); + invalidateCustomThemes(); + const label = choice === "Update existing" ? "Dusk" : "Dusk (1)"; + expect(getCustomThemes().find((theme) => theme.label === label)?.colors).toEqual( + parseThemeFile(theme("Dusk", "#f97316")).colors, + ); + await expect.element(page.getByText(/^broken\.json:/)).toBeVisible(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }, +); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index 8536498ba5a68..e9d37f45322d1 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -284,11 +284,10 @@ function ThemeImportBody({ } } if (installed.length > 0) onImportedMany(installed, { updated: false }); - if (failures.length > 0) { - setError(failures.join("; ")); - } else if (conflicting.length > 0) { + setError(failures.length > 0 ? failures.join("; ") : null); + if (conflicting.length > 0) { setConflicts(conflicting); - } else if (installed.length > 0) { + } else if (installed.length > 0 && failures.length === 0) { onClose(); } }; @@ -387,8 +386,8 @@ function ThemeImportBody({ } if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" }); setConflicts(null); - if (failures.length > 0) setError(failures.join("; ")); - else onClose(); + if (failures.length > 0) setError([error, ...failures].filter(Boolean).join("; ")); + else if (!error) onClose(); }; const handleSubmit = () => { From 061dc79e3bf7a56709fda2c5dd71b6dcefe4d787 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:54:47 -0500 Subject: [PATCH 50/86] fix(usage): reject cyclic model mappings before saving (#23963) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../components/usage/usagePriceTable.test.ts | 49 ++++++++++++++++++- .../src/components/usage/usagePriceTable.ts | 20 ++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/usagePriceTable.test.ts b/apps/web/src/components/usage/usagePriceTable.test.ts index 6a1c1e71ecb44..2ef6ea31c9b37 100644 --- a/apps/web/src/components/usage/usagePriceTable.test.ts +++ b/apps/web/src/components/usage/usagePriceTable.test.ts @@ -1,6 +1,11 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { usagePriceCell, usagePriceTableChanges, type UsagePriceDraft } from "./usagePriceTable"; +import { + usagePriceCell, + usagePriceTableChanges, + usagePriceTableErrors, + type UsagePriceDraft, +} from "./usagePriceTable"; import type { UsagePriceTarget } from "./usagePriceTargets"; const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; @@ -116,3 +121,45 @@ describe("price table edits", () => { ).toBe("Map to a different model."); }); }); + +describe("model mapping validation", () => { + const mapping = (model: string, alias: string): UsagePriceDraft => ({ + id: `model:${model}`, + model, + alias, + isNew: false, + values: {}, + }); + + it("rejects a cycle created by several mappings in one save", () => { + const edits = [mapping("a", "b"), mapping("b", "c"), mapping("c", "a")]; + const result = usagePriceTableChanges(target("local", {}), edits); + expect([...result.errors.keys()]).toEqual(["model:a", "model:b", "model:c"]); + }); + + it("checks each environment's saved chain when an edited mapping closes it", () => { + const edits = [mapping("c", "a")]; + const acyclic = target("one", {}, { a: "b" }); + const cyclic = target("two", {}, { a: "b", b: "c" }); + expect(usagePriceTableChanges(acyclic, edits).errors.size).toBe(0); + expect(usagePriceTableErrors([acyclic, cyclic], edits).get("model:c")).toBe( + "Model mappings cannot form a cycle on two.", + ); + }); + + it("validates the final batch so a mapping can be reversed in either row order", () => { + const environment = target("local", {}, { constructor: "__proto__" }); + const clear = mapping("constructor", ""); + const reverse = mapping("__proto__", "constructor"); + for (const edits of [ + [reverse, clear], + [clear, reverse], + ]) { + const result = usagePriceTableChanges(environment, edits); + expect(result.errors.size).toBe(0); + expect(result.changes).toEqual( + edits.map(({ model, alias }) => ({ model, alias: alias || null })), + ); + } + }); +}); diff --git a/apps/web/src/components/usage/usagePriceTable.ts b/apps/web/src/components/usage/usagePriceTable.ts index e6ad3d72da8bb..04b667c2ec30d 100644 --- a/apps/web/src/components/usage/usagePriceTable.ts +++ b/apps/web/src/components/usage/usagePriceTable.ts @@ -115,6 +115,26 @@ export function usagePriceTableChanges( if (USAGE_PRICE_FIELDS.some((field) => original[field.key] !== next[field.key])) changes.push(parsed); } + // Check the final batch so clearing a mapping and reversing it in one save stays valid. + const aliases = new Map(Object.entries(target.aliases ?? {})); + for (const change of changes) { + if (!("alias" in change)) continue; + if (change.alias === null) aliases.delete(change.model); + else aliases.set(change.model, change.alias); + } + for (const draft of drafts) { + if (draft.removed || errors.has(draft.id)) continue; + let model = draft.model.trim(); + const seen = new Set(); + while (aliases.has(model)) { + if (seen.has(model)) { + errors.set(draft.id, `Model mappings cannot form a cycle on ${target.label}.`); + break; + } + seen.add(model); + model = aliases.get(model)!; + } + } return { changes, errors }; } From bae3c1962ce31eefd09e9625586cbd6a904bedc2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:55:10 -0500 Subject: [PATCH 51/86] fix(settings): offer Use existing CLI in managed Codex settings (#23160) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../components/settings/CodexSetupSection.tsx | 12 +++- ...SettingsPanel.environment.browser.test.tsx | 58 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx index bab910d5eda82..e7ef1c9a20404 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -914,7 +914,7 @@ function ManagedCodexSetup({ title="ChatGPT account" description={accountDescription} control={ -
+
{authActive ? ( <> {waitingControl} @@ -1001,6 +1001,16 @@ function ManagedCodexSetup({ Use a different account ) : null} + {allowExistingCli ? ( + + ) : null} )}
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index 89fa5250a3a09..4ad9b3986bb3a 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -4,6 +4,7 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId, + type ProviderAuthState, type ServerProvider, type UnifiedSettings, } from "@t3tools/contracts"; @@ -17,6 +18,8 @@ import { act, render } from "../../../test/render"; const state = vi.hoisted(() => ({ providers: null as unknown as Atom.Writable>, + providerAuthQuery: null as unknown, + providerAuth: null as ProviderAuthState | null, settings: null as unknown as UnifiedSettings, canManageProviders: true, canWriteSettings: true, @@ -40,6 +43,8 @@ vi.mock(import("../../state/server"), async (original) => { const providers = Atom.make>([]).pipe(Atom.keepAlive); state.providers = providers; const config = Atom.make(null); + const providerAuth = Atom.make(null); + state.providerAuthQuery = providerAuth; const commandLabels = { refreshProviders: "refresh", updateProvider: "update", @@ -59,6 +64,7 @@ vi.mock(import("../../state/server"), async (original) => { ...actual.serverEnvironment, ...labelled, configValueAtom: (() => config) as never, + providerAuthState: (() => providerAuth) as never, providersValueAtom: (() => providers) as never, } as never, }; @@ -75,8 +81,8 @@ vi.mock(import("../../state/use-atom-command"), async (original) => ({ })); vi.mock(import("../../state/query"), async (original) => ({ ...(await original()), - useEnvironmentQuery: (() => ({ - data: null, + useEnvironmentQuery: ((query: unknown) => ({ + data: query === state.providerAuthQuery ? state.providerAuth : null, error: null, isPending: false, refresh: vi.fn(), @@ -216,6 +222,7 @@ async function setup() { state.canManageProviders = true; state.canWriteSettings = true; state.searchTargetId = null; + state.providerAuth = null; state.readEnvironmentIds = []; state.updateEnvironmentIds = []; state.mutationEnvironmentIds = []; @@ -302,4 +309,51 @@ describe("EnvironmentProviderSettings routing", () => { input: { instanceId, elicitationId: action.elicitationId }, }); }); + + it("lets a signed-out managed Codex account switch to the existing CLI", async () => { + await setup(); + const codex = ProviderDriverKind.make("codex"); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: codex, + enabled: true, + config: { enabled: true, setupMode: "managed" }, + }, + }, + }; + state.providerAuth = { + instanceId: codexId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + methods: [{ id: "chatgpt-profile:work", name: "Work", description: null, type: "agent" }], + }; + await setProviders([ + { + ...provider(), + auth: { status: "unauthenticated" }, + setup: { canAuthenticate: true, canInstall: true }, + }, + ]); + await using _screen = await render(panel({ targetInstanceId: codexId })); + await expect.element(page.getByRole("button", { name: "Reconnect account" })).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Use existing CLI" }).element()); + + expect(state.mutateProviderInstance).toHaveBeenCalledWith( + { + operation: "upsert", + instanceId: codexId, + instance: { + driver: codex, + enabled: true, + config: { enabled: true, setupMode: "existing" }, + }, + }, + {}, + ); + }); }); From 1e95da77c41968eee9e7fce1f6d29b27a9e39339 Mon Sep 17 00:00:00 2001 From: parmartejas <110296251+parmartejass@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:25:32 +0530 Subject: [PATCH 52/86] fix(git): honor project provider settings (#23672) Co-authored-by: Tejas Parmar Co-authored-by: maria-rcks --- .../features/threads/DraftThreadHeader.tsx | 6 ++- apps/server/src/git/GitManager.test.ts | 41 ++++++++++++++++++- apps/server/src/git/GitManager.ts | 24 +++++++---- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/DraftThreadHeader.tsx b/apps/mobile/src/features/threads/DraftThreadHeader.tsx index 509545b58c8bb..5c3d758f45513 100644 --- a/apps/mobile/src/features/threads/DraftThreadHeader.tsx +++ b/apps/mobile/src/features/threads/DraftThreadHeader.tsx @@ -87,7 +87,11 @@ export function DraftThreadHeader(props: { }, onRunAction: async (input) => { if (!canWriteGit || !props.cwd) return null; - const result = await runAction({ ...input, actionId: uuidv4() }); + const result = await runAction({ + ...input, + projectId: props.project.id, + actionId: uuidv4(), + }); if (AsyncResult.isFailure(result)) { Dialog.alert("Git action failed", String(Cause.squash(result.cause))); return null; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 5d363826458df..3c13991426f92 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2599,7 +2599,45 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("applies a V2-native thread's project settings to generated commit text", () => + it.effect("uses an explicit project's provider settings for generated commit text", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nproject\n"); + const projectId = ProjectId.make("project:git-action-settings"); + const disabledInstanceId = DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId; + let generatedModelSelection: + | TextGeneration.CommitMessageGenerationInput["modelSelection"] + | undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [disabledInstanceId]: { enabled: false } }, + }, + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedModelSelection = input.modelSelection; + return Effect.succeed({ subject: "Use the project writer", body: "" }); + }, + }, + }); + + yield* manager.runStackedAction({ + actionId: "test-project-action-settings", + cwd: repoDir, + action: "commit", + projectId, + }); + + expect(generatedModelSelection?.instanceId).toBe(ProviderInstanceId.make("claudeAgent")); + }), + ); + + it.effect("prefers a V2-native thread's project settings over an explicit project", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2686,6 +2724,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { cwd: repoDir, action: "commit", threadId, + projectId: ProjectId.make("project:other"), }); expect(generatedModelSelection).toEqual(projectModel); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 53d0b4f5853ff..71e5edb3b697b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -672,19 +672,25 @@ export const make = Effect.gen(function* () { const projectSettingsFor = Effect.fnUntraced(function* (input: { readonly cwd: string; readonly threadId?: ThreadId | undefined; + readonly projectId?: ProjectId | undefined; }) { const settings = yield* serverSettingsService.getSettings; if (!hasProjectSettingsOverrides(settings)) return { settings, projectId: null as ProjectId | null }; - const projectId: ProjectId | null = yield* input.threadId !== undefined - ? threads.getThreadShell(input.threadId).pipe( - Effect.map((thread) => thread?.projectId ?? null), - Effect.orElseSucceed(() => null), - ) - : projects.findActiveByWorkspaceRoot(input.cwd).pipe( - Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), - Effect.orElseSucceed(() => null), - ); + const threadProjectId = + input.threadId === undefined + ? null + : yield* threads.getThreadShell(input.threadId).pipe( + Effect.map((thread) => thread?.projectId ?? null), + Effect.orElseSucceed(() => null), + ); + const projectId = + threadProjectId ?? + input.projectId ?? + (yield* projects.findActiveByWorkspaceRoot(input.cwd).pipe( + Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); return { settings: resolveProjectSettings(settings, projectId).settings, projectId }; }); const readThreadContext = Effect.fnUntraced( From efb940dbaf97674c455f9f74d33ca5fc34a1de83 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:55:54 +0000 Subject: [PATCH 53/86] fix(desktop): force-close stalled Linux MIME cache helpers (#25210) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../src/app/DesktopLinuxUrlHandler.test.ts | 71 +++++++++++++++++++ .../desktop/src/app/DesktopLinuxUrlHandler.ts | 1 + 2 files changed, 72 insertions(+) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index feedbbd580074..9404b0ac3087c 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,7 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -9,6 +13,8 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -56,6 +62,7 @@ const layerHandler = ( recorded: RecordedRegistration, input: { readonly environment?: Record; + readonly processSpawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly updateDesktopDatabaseExitCode?: number; readonly updateDesktopDatabaseStalled?: boolean; readonly updateDesktopDatabaseStarted?: Deferred.Deferred; @@ -106,6 +113,7 @@ const layerHandler = ( Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { + if (input.processSpawner) return input.processSpawner.spawn(command); const childProcess = command as unknown as { readonly command: string; readonly args: ReadonlyArray; @@ -358,6 +366,69 @@ describe("DesktopLinuxUrlHandler", () => { assert.deepEqual(writeFailed.commands, []); }); }); + + it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "continues registration after a timed-out cache helper ignores SIGTERM", + () => + Effect.gen(function* () { + const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const nodeExecutable = yield* HostProcess.ExecutablePath; + const recorded = emptyRecording(); + const spawned = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const processSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a single registration command"); + } + recorded.commands.push({ command: command.command, args: command.args }); + if (command.command !== "update-desktop-database") return mockProcess(0); + const child = yield* realSpawner.spawn( + ChildProcess.make( + nodeExecutable, + [ + "-e", + // The watchdog makes the unfixed case exit with a distinguishable code. + "process.on('SIGTERM', () => setTimeout(() => process.exit(99), 5000));" + + "process.stdout.write('ready\\n'); setInterval(() => {}, 2147483647);", + ], + { ...command.options, stdout: "pipe" }, + ), + ); + yield* Deferred.succeed(spawned, child); + yield* Stream.runHead(child.stdout); + yield* Deferred.succeed(ready, undefined); + return child; + }), + ); + const registration = yield* runRegister(recorded, { processSpawner }).pipe( + Effect.forkScoped, + ); + const child = yield* Deferred.await(spawned); + yield* Effect.addFinalizer(() => + child.isRunning.pipe( + Effect.flatMap((running) => + running ? child.kill({ killSignal: "SIGKILL" }) : Effect.void, + ), + Effect.ignore, + ), + ); + yield* Deferred.await(ready); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(registration); + + const exit = yield* Effect.exit(child.exitCode); + if (Exit.isFailure(exit)) { + assert.include(Cause.pretty(exit.cause), "SIGKILL"); + } else { + assert.fail(`cache helper exited with ${exit.value} instead of being force-killed`); + } + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); interface ThemeIconRun { diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index dd38acbc3894e..ebd7d71b25726 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -174,6 +174,7 @@ export const make = Effect.gen(function* () { stdin: "ignore", stdout: "ignore", stderr: "ignore", + forceKillAfter: "1 second", }, ); const handle = yield* spawner.spawn(command); From bdf84df8ca24bd6ab6f4cdbadef2f8a205da4441 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:56:17 -0400 Subject: [PATCH 54/86] fix(cli): auth pairing create rejects a --base-url without http(s):// (#23343) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/cli/auth.ts | 12 ++++++- apps/server/src/cli/pair.test.ts | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 0aeb7d6a3b5ab..06b949e3013ab 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -68,6 +69,15 @@ const subjectFlag = Flag.String("subject").pipe( ); const baseUrlFlag = Flag.String("base-url").pipe( + Flag.withSchema( + Schema.URLFromString.pipe( + Schema.check( + Schema.makeFilter((url) => url.protocol === "http:" || url.protocol === "https:", { + expected: "an http:// or https:// URL", + }), + ), + ), + ), Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), Flag.optional, ); @@ -99,7 +109,7 @@ const pairingCreateCommand = Command.make("create", { }); const output = formatIssuedPairingCredential(issued, { json: flags.json, - ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), + ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value.href } : {}), }); yield* Console.log(output); }), diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 5ad9bde0acc28..88fc9cec74c13 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -488,3 +488,63 @@ describe("auth scope options", () => { }), ); }); + +describe("auth pairing create --base-url", () => { + it.effect.each(["192.168.1.5:3773", "localhost:3773", "ftp://localhost:3773"])( + "rejects %s before issuing a token", + (baseUrl) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-base-url-test-")); + const error = yield* runCli([ + "auth", + "pairing", + "create", + "--base-dir", + baseDir, + "--base-url", + baseUrl, + ]).pipe(Effect.provide(layerCliRuntime), Effect.flip); + + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + const flagError = error.errors[0]; + if (flagError?._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(flagError?._tag)}`); + } + assert.equal(flagError.option, "base-url"); + assert.equal(flagError.value, baseUrl); + + const listOutput = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + assert.deepEqual(JSON.parse(listOutput), []); + }), + ); + + it.effect.each(["http://192.168.1.5:3773", "https://example.com", "http:localhost:3773"])( + "prints a pair URL for %s", + (baseUrl) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-base-url-test-")); + const output = yield* captureStdout( + runCli([ + "auth", + "pairing", + "create", + "--base-dir", + baseDir, + "--base-url", + baseUrl, + "--json", + ]), + ); + const issued = JSON.parse(output) as { + readonly credential: string; + readonly pairUrl: string; + }; + + assert.equal(issued.pairUrl, `${new URL(baseUrl).origin}/pair#token=${issued.credential}`); + }), + ); +}); From 4afdabab4479a68c7ea02d09174fe7936f084882 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:56:40 -0500 Subject: [PATCH 55/86] fix(server): signed asset and upload URLs stay out of HTTP traces (#24556) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/http.test.ts | 63 ++++++++++++++++++++++++++++++++++++ apps/server/src/http.ts | 9 ++++-- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 0319bbb55c03d..5f7df20c738c8 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -13,7 +13,10 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; +import * as Tracer from "effect/Tracer"; import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; +import { ASSET_ROUTE_PREFIX } from "./assets/AssetAccess.ts"; +import { ATTACHMENT_UPLOAD_ROUTE_PREFIX } from "./assets/AttachmentUpload.ts"; import { openMediaFile } from "./assets/MediaFile.ts"; import { @@ -139,6 +142,66 @@ describe("browser API CORS", () => { }); }); +describe("untraced requests", () => { + it.effect("keeps signed asset and upload URLs out of HTTP server spans", () => + Effect.gen(function* () { + const serverSpans: Array = []; + const layerTracer = Layer.succeed( + Tracer.Tracer, + Tracer.make({ + span: (options) => { + if (options.kind === "server") serverSpans.push(options.name); + return new Tracer.NativeSpan(options); + }, + }), + ); + const layerRoutes = Layer.effectDiscard( + Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + yield* router.add("GET", `${ASSET_ROUTE_PREFIX}/*`, HttpServerResponse.empty()); + yield* router.add( + "POST", + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, + HttpServerResponse.empty(), + ); + yield* router.add("GET", "/api/environment", HttpServerResponse.empty()); + }), + ); + const services = yield* Layer.build( + HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( + ServerHttp.withUntracedRequests, + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provide(layerTracer), + ), + ); + const client = Context.get(services, HttpClient.HttpClient); + + // Each URL is a bearer credential: url.full and url.path would carry it. + for (const request of [ + HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), + HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png?v=1`), + HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature`), + HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature?v=1`), + HttpClientRequest.head(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), + ]) { + expect((yield* client.execute(request)).status).toBe(204); + } + expect(serverSpans).toEqual([]); + + expect((yield* client.execute(HttpClientRequest.get("/api/environment"))).status).toBe(204); + expect(serverSpans).toEqual(["GET"]); + + for (const path of [ + `${ASSET_ROUTE_PREFIX}-other/file`, + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}-other/token`, + ]) { + expect((yield* client.execute(HttpClientRequest.get(path))).status).toBe(404); + } + expect(serverSpans).toEqual(["GET", "GET", "GET"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + const layerFileResponse = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (staticDir: string) { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8f9737a3efc18..e792a233c6f06 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -391,8 +391,13 @@ const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_P const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { const queryIndex = request.url.indexOf("?"); const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex); - // Webhook URLs carry their secret token in the path, so they never reach a trace. - return UNTRACED_REQUEST_PATHS.has(path) || path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`); + // Signed URLs carry bearer tokens in the path, so they never reach a trace. + return ( + UNTRACED_REQUEST_PATHS.has(path) || + path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`) || + path.startsWith(`${ASSET_ROUTE_PREFIX}/`) || + path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) + ); }); export const withUntracedRequests = Layer.provide(layerUntracedRequests); From 1cc9d413a52c05ad51df5faa8d11437ca97043c7 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:57:04 -0500 Subject: [PATCH 56/86] fix(server): clone errors no longer show a token from the URL's query (#24698) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../SourceControlRepositoryService.test.ts | 56 +++++++++++++++++++ .../SourceControlRepositoryService.ts | 16 ++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 4a9105e9f5d9e..b0770b06d4830 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -383,6 +383,62 @@ it.effect.each([ }), ); +it.effect.each([ + ["a plain token", "s3cret"], + ["a token with an apostrophe", "s3'cret"], + ["a token with a double quote", 's3"cret'], + ["a host-only token with an at sign", "abc@secret"], + ["an unquoted token with an apostrophe", "s3'cret", true], + ["an unquoted token with a double quote", 's3"cret', true], + ["an unquoted URL with an apostrophe in its path", "path-token", true], +] as const)("keeps %s in the query out of a failed clone's error text", ([, token, unquoted]) => + Effect.gen(function* () { + const remote = + token === "abc@secret" + ? "https://github.com" + : token === "path-token" + ? "https://github.com/octocat/t3code's.git" + : "https://github.com/octocat/t3code.git"; + const error = yield* Effect.flip( + cloneInTemporaryDirectory( + { + execute: (input) => + Effect.gen(function* () { + const cloneUrl = input.args.at(-2)!; + // Git treats @ in a pathless URL's query as userinfo, losing the query delimiter. + const diagnosticUrl = + cloneUrl === "https://github.com?access_token=abc@secret" + ? "https://secret" + : cloneUrl; + yield* ( + input.progress?.onStderrLine?.( + unquoted + ? `fatal: ${diagnosticUrl}/info/refs not valid: is this a git repository?` + : `fatal: unable to access '${diagnosticUrl}/': Could not resolve host: github.com`, + ) ?? Effect.void + ); + return yield* new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + detail: "Git command exited with a non-zero status.", + exitCode: 128, + }); + }), + }, + { remoteUrl: `${remote}?access_token=${token}` }, + ), + ); + + assert.strictEqual( + error.detail, + unquoted + ? `fatal: ${remote} not valid: is this a git repository?` + : `fatal: unable to access '${remote}${token === "abc@secret" ? "/" : ""}': Could not resolve host: github.com`, + ); + }), +); + it.effect("strips embedded credentials from the remote URL it reports", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index b6e7679037040..44d31a43b6978 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -110,7 +110,7 @@ function toRepositoryInfo( }; } -// Redact credentials from client clone snapshots; git still receives the original URL. +// Redact credentials from client clone snapshots; git still receives the credentials. function redactRemoteUrl(remoteUrl: string): string { try { const url = new URL(remoteUrl); @@ -128,10 +128,16 @@ function redactRemoteUrl(remoteUrl: string): string { } // Userinfo can contain @; remove through the last one before the host boundary. -const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; +const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#]+@/gi; +// Git leaves quotes inside query tokens unescaped; consume through the closing quote. +const QUOTED_URL_QUERY = /(['"])([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?.*\1(?=[\s:]|$)/gi; +const URL_QUERY = /\b([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?[^\s]*/gi; function redactUrlCredentials(text: string): string { - return text.replace(URL_WITH_USERINFO, "$1"); + return text + .replace(QUOTED_URL_QUERY, "$1$2$1") + .replace(URL_QUERY, "$1") + .replace(URL_WITH_USERINFO, "$1"); } // The remote already exists after push failure; advise fixing access and pushing. @@ -340,11 +346,13 @@ export const make = Effect.gen(function* () { if (stderrTail.length > 4) stderrTail.shift(); }); }; + // Git misreads @ in a pathless URL's query as userinfo and echoes its suffix as the host. + const normalizedCloneUrl = cloneUrl.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)\?/i, "$1/?"); yield* git .execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: path.dirname(destinationPath), - args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], + args: ["clone", "--progress", "--", normalizedCloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, From c9d3f7c9f4c312e61990ea5184afd9c3056b4791 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:57:27 -0500 Subject: [PATCH 57/86] fix(claude): pass every --add-dir launch directory to Claude (#23281) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../Adapters/ClaudeAdapterV2.test.ts | 22 ++++++++++ .../Adapters/ClaudeAdapterV2.ts | 8 +++- .../src/provider/Drivers/ClaudeSkills.test.ts | 2 + .../src/provider/Drivers/ClaudeSkills.ts | 41 ++++++++++++------- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 38253cd82f1d1..fb17c34713fd8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -268,6 +268,28 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.isUndefined(options.extraArgs?.["dangerously-skip-permissions"]); }); + it("passes every --add-dir launch directory to Claude", () => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-add-dir", + resume: false, + cwd: "/workspace", + attachmentsDir: "/attachments", + settings: { + ...AUTO_COMPACT_CLAUDE_SETTINGS, + launchArgs: "--add-dir ../apps ../lib --verbose --add-dir=/shared", + }, + }); + assert.deepEqual(options.additionalDirectories, [ + "/workspace", + "/attachments", + "../apps", + "../lib", + "/shared", + ]); + assert.isUndefined(options.extraArgs?.["add-dir"]); + }); + it("gives delegated children Claude's subagent prompt cache TTL", () => { const lineage = (relationshipToParent: "fork" | "subagent" | null) => ({ lineage: { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b1273cee72061..496a0b2e77d0f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -135,7 +135,10 @@ import { planClaudeSkillDispatch, type ClaudeSkillTarget, } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; -import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; +import { + claudeLaunchArgDirectories, + discoverClaudeSkills, +} from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection, type CompiledClaudeModelSelection, @@ -1473,6 +1476,8 @@ export function makeClaudeQueryOptions(input: { }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection); const { + // claudeLaunchArgDirectories reads every --add-dir into additionalDirectories. + "add-dir": _launchArgAddDir, "permission-mode": launchArgPermissionMode, "dangerously-skip-permissions": launchArgSkipPermissions, "append-system-prompt": launchArgAppendSystemPrompt, @@ -1592,6 +1597,7 @@ export function makeClaudeQueryOptions(input: { const additionalDirectories = [ ...(input.cwd === null ? [] : [input.cwd]), ...(input.attachmentsDir === undefined ? [] : [input.attachmentsDir]), + ...claudeLaunchArgDirectories(input.settings?.launchArgs), ]; const withDirectories = additionalDirectories.length === 0 ? options : { ...options, additionalDirectories }; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 2897cc0b84282..860280aa91e89 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -358,6 +358,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { '--add-dir "extra skills"', '--add-dir="extra skills"', `--add-dir missing --add-dir "${extraDir}"`, + `--add-dir "${extraDir}" --add-dir missing`, + `--add-dir missing "${extraDir}"`, ]) { const settings = { homePath: configDir, launchArgs }; const skills = yield* discoverClaudeSkills(settings, workspace); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 56c2ff19bebdf..bc68e42b76a2a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { parse as parseYamlDocument } from "yaml"; @@ -427,6 +427,26 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); +// Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. +export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray { + const directories: string[] = []; + let collecting = false; + for (const token of tokenizeCliArgs(launchArgs)) { + if (token === "--") break; + if (token === "--add-dir") { + collecting = true; + } else if (token.startsWith("--add-dir=")) { + directories.push(token.slice("--add-dir=".length)); + collecting = false; + } else if (token.startsWith("-")) { + collecting = false; + } else if (collecting) { + directories.push(token); + } + } + return directories; +} + /** First name wins, matching Claude Code's precedence of user skills over project skills. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick & Partial>, @@ -442,22 +462,15 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* environment ?? process.env, ); - const additionalDirectory = parseCliArgs(config.launchArgs ?? "").flags["add-dir"]; const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), - ...(additionalDirectory && (cwd || path.isAbsolute(additionalDirectory)) - ? [ - { - directory: path.join( - path.resolve(cwd ?? ".", additionalDirectory), - ".claude", - "skills", - ), - scope: "project" as const, - }, - ] - : []), + ...claudeLaunchArgDirectories(config.launchArgs) + .filter((directory) => directory && (cwd || path.isAbsolute(directory))) + .map((directory) => ({ + directory: path.join(path.resolve(cwd ?? ".", directory), ".claude", "skills"), + scope: "project" as const, + })), ...(yield* discoverPluginSkillRoots( configDirPath, cwd, From 4b7a347ca9372aea7e2cc93d8b2835cdf34f69eb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:57:51 -0500 Subject: [PATCH 58/86] fix(composer): retry expired catalogs after failed discovery (#23174) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../client-runtime/src/providerSkills.test.ts | 57 +++++++++++++++++++ packages/client-runtime/src/providerSkills.ts | 21 +++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 7539861026165..ccc5bf6ef2807 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -199,6 +199,63 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); + it.each(["already expired", "expires during discovery"])( + "retries an unchanged catalog that %s after the short cooldown", + async (expiry) => { + const start = Date.parse("2026-01-01T00:05:00.000Z"); + vi.useFakeTimers({ now: expiry === "expires during discovery" ? start - 1 : start }); + const stale = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, responses, run, unmount } = setup( + expiry === "expires during discovery" + ? withSnapshot("2026-01-01T00:00:00.000Z", true) + : stale, + [ + () => { + if (expiry === "expires during discovery") vi.setSystemTime(start + 1); + return stale; + }, + ], + ); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(9_999); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + responses.push(() => withSnapshot("2026-01-01T00:05:10.000Z")); + await vi.advanceTimersByTimeAsync(1); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(2); + unmount(); + }, + ); + + it("keeps the TTL guard for a newly scanned catalog when the client clock is ahead", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T01:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z"), [ + () => withSnapshot("2026-01-01T00:01:00.000Z"), + ]); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("bounds retries when discovery repeatedly returns the same expired catalog", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:05:00.000Z") }); + const stale = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, run, unmount } = setup(stale, [() => stale, () => stale, () => stale]); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(3); + unmount(); + }); + it("does not rescan a current snapshot, and rescans once it ages past the TTL", () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z")); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index e0e15450669f8..63ded7a47dc5c 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -144,7 +144,7 @@ interface ProviderWorkspaceSnapshotRefreshState { readonly requestedAt: number | null; /** A failed scan is not retried before this time. */ readonly retryNotBefore: number | null; - /** Scans in a row that ended without a complete snapshot. */ + /** Failed scans within the current request TTL. */ readonly failedAttempts: number; readonly hadSnapshot: boolean; } @@ -183,8 +183,10 @@ function planProviderWorkspaceSnapshotRefresh( const state = { requestedAt, retryNotBefore, - // A complete snapshot ends the run of failures. - failedAttempts: hasSnapshot ? 0 : previous.failedAttempts, + failedAttempts: + lost || (requestedAt !== null && now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) + ? 0 + : previous.failedAttempts, hadSnapshot: hasSnapshot, }; const refresh = @@ -305,9 +307,18 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { { environmentId, input: { instanceId, cwd } }, { reportFailure: false }, ).then((result) => { + const refreshedProvider = + result._tag === "Success" ? findProvider(result.value.providers) : undefined; + const snapshot = + refreshedProvider && resolveProviderWorkspaceSnapshot(refreshedProvider, cwd); + // A new server timestamp proves progress even when the client clock is ahead. + // @effect-diagnostics-next-line globalDate:off + const completedAt = Date.now(); const refreshed = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot(findProvider(result.value.providers), cwd); + hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) && + (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) || + snapshot?.checkedAt !== + (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt); if (!refreshed) retryLater(); }, retryLater); return plan.state; From 7860ee983d81d287eaf15664ffd92dc6964d92cc Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:58:14 +0000 Subject: [PATCH 59/86] fix(auth): preserve pairing codes after scope rejection (#25222) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- apps/server/src/auth/EnvironmentAuth.test.ts | 29 +++++++++++++++++++ apps/server/src/auth/EnvironmentAuth.ts | 15 ++++++---- apps/server/src/auth/McpOAuth.test.ts | 3 +- apps/server/src/auth/PairingGrantStore.ts | 15 +++++++--- .../src/persistence/AuthPairingLinks.ts | 17 ++++++++++- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 8558a6664c8a6..bd5d3b569958f 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -377,6 +377,35 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(layerEnvironmentAuth())), ); + it.effect( + "preserves a rejected MCP pairing code for exactly one concurrent lower-access retry", + () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairing = yield* serverAuth.issuePairingCredential({ + scopes: ["orchestration:read"], + }); + + const rejected = yield* Effect.flip( + serverAuth.consumeMcpApprovalCode(pairing.credential, "full-access"), + ); + expect(rejected).toMatchObject({ + _tag: "ServerAuthMcpApprovalCodeError", + reason: "insufficient_scope", + }); + + const retry = Effect.result( + serverAuth.consumeMcpApprovalCode(pairing.credential, "read-only"), + ); + const results = yield* Effect.all([retry, retry], { concurrency: "unbounded" }); + + expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); + expect(results.filter((result) => result._tag === "Failure")).toMatchObject([ + { failure: { _tag: "ServerAuthMcpApprovalCodeError", reason: "unknown_or_used" } }, + ]); + }).pipe(Effect.provide(layerEnvironmentAuth())), + ); + it.effect("narrows seeded desktop grants to the requested scopes", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 3fbe5a0061c0e..7d73e65925228 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -429,7 +429,7 @@ export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError; + readonly requiredScopes?: ReadonlyArray; }, ): Effect.Effect< ResolvedBootstrapGrant, @@ -920,6 +921,12 @@ export const make = Effect.gen(function* () { } return sessions.verify(credential).pipe( mapSessionVerificationErrors, + Effect.filterOrFail( + (session) => + input?.requiredScopes === undefined || + input.requiredScopes.every((scope) => session.scopes.includes(scope)), + () => new ServerAuthScopeNotGrantedError({}), + ), Effect.map( (session) => ({ @@ -1340,7 +1347,7 @@ export const make = Effect.gen(function* () { code, access, ) => - resolveBootstrapGrant(code.trim()).pipe( + resolveBootstrapGrant(code.trim(), { requiredScopes: mcpClientScopes(access) }).pipe( Effect.catchTags({ ServerAuthInvalidCredentialError: () => Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), @@ -1350,9 +1357,7 @@ export const make = Effect.gen(function* () { Effect.flatMap((grant) => grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" ? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" })) - : mcpClientScopes(access).every((scope) => grant.scopes.includes(scope)) - ? Effect.void - : Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), + : Effect.void, ), Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"), ); diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 3dc35aca981b9..29588040217ae 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -457,11 +457,10 @@ it.live( }); expect(operatorFull.status).toBe(400); - const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); const readOnlyApproval = yield* decide(handler, params, { _tag: "pairing-code", access: "read-only", - code: readOnlyCode.credential, + code: readOnly.credential, }); expect(readOnlyApproval.status).toBe(200); const readOnlyToken = yield* handler( diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 440dbe3788a45..0709f371684fb 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -226,6 +226,8 @@ export class PairingGrantStore extends Context.Service< input?: { readonly proofKeyThumbprint?: string; readonly requestedScopes?: ReadonlyArray; + /** All scopes must be granted; requestedScopes only requires an overlap for OAuth. */ + readonly requiredScopes?: ReadonlyArray; }, ) => Effect.Effect; } @@ -486,8 +488,10 @@ const make = Effect.gen(function* () { } if ( - input?.requestedScopes !== undefined && - !input.requestedScopes.some((scope) => grant.scopes.includes(scope)) + (input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => grant.scopes.includes(scope))) || + (input?.requiredScopes !== undefined && + !input.requiredScopes.every((scope) => grant.scopes.includes(scope))) ) { return [ { @@ -550,6 +554,7 @@ const make = Effect.gen(function* () { ...(input?.requestedScopes !== undefined ? { requestedScopes: input.requestedScopes } : {}), + ...(input?.requiredScopes !== undefined ? { requiredScopes: input.requiredScopes } : {}), consumedAt: now, now, }) @@ -596,8 +601,10 @@ const make = Effect.gen(function* () { } if ( - input?.requestedScopes !== undefined && - !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope)) + (input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))) || + (input?.requiredScopes !== undefined && + !input.requiredScopes.every((scope) => matching.value.scopes.includes(scope))) ) { return yield* new BootstrapCredentialScopeNotGrantedError({}); } diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index 4e2d572bd96b4..8754bdbd2edcd 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -48,6 +48,7 @@ export const ConsumeAuthPairingLinkInput = Schema.Struct({ credential: Schema.String, proofKeyThumbprint: Schema.NullOr(Schema.String), requestedScopes: Schema.optionalKey(AuthEnvironmentScopes), + requiredScopes: Schema.optionalKey(AuthEnvironmentScopes), consumedAt: Schema.DateTimeUtcFromString, now: Schema.DateTimeUtcFromString, }); @@ -161,7 +162,14 @@ export const make = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, Result: AuthPairingLinkRawDbRow, - execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) => + execute: ({ + credential, + proofKeyThumbprint, + requestedScopes, + requiredScopes, + consumedAt, + now, + }) => sql` UPDATE auth_pairing_links SET consumed_at = ${consumedAt} @@ -180,6 +188,13 @@ export const make = Effect.gen(function* () { SELECT value FROM json_each(auth_pairing_links.scopes) ) )) + AND NOT EXISTS ( + SELECT 1 + FROM json_each(${JSON.stringify(requiredScopes ?? [])}) AS required + WHERE required.value NOT IN ( + SELECT value FROM json_each(auth_pairing_links.scopes) + ) + ) RETURNING id AS "id", credential AS "credential", From 04f35381b65015f5204c2ceb11a94ba931a38fea Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:58:36 +0000 Subject: [PATCH 60/86] fix(mobile): discard unchanged queued edits after starting (#25232) Co-authored-by: Tristan Knight <18444302+tris203@users.noreply.github.com> --- apps/mobile/src/state/queued-run-edit.test.ts | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 361a743ac71b5..6670505ad3988 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { Atom } from "effect/reactivity"; import { ChatAttachmentId, @@ -20,7 +20,25 @@ import { type QueuedRunEdit, } from "./queued-run-edit"; import { threadComposerErrorsAtom } from "./thread-composer-error"; -import { getComposerDraftSnapshot, setComposerDraftText } from "./use-composer-drafts"; +import { + flushComposerDrafts, + getComposerDraftSnapshot, + setComposerDraftText, + waitForComposerDraftsLoaded, +} from "./use-composer-drafts"; + +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + File: class { + exists = false; + create() {} + write() {} + moveSync() {} + }, + Paths: { document: { uri: "file:///documents" } }, +})); const image = (id: string): ChatAttachment => ({ type: "image", @@ -94,6 +112,64 @@ describe("resolveQueuedEditPayload", () => { }); describe("beginQueuedRunEdit", () => { + it("silently drops an untouched edit when its run starts", () => { + const threadKey = "env-exit:thread-untouched"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([]), runs); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe(""); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBeUndefined(); + }); + + it("recovers text after a server attachment was removed", async () => { + await waitForComposerDraftsLoaded(); + const threadKey = "env-exit:thread-removed-recover"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([image("removed")]), runs); + removeQueuedRunEditAttachment(threadKey, "removed"); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + await flushComposerDrafts(); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe("original"); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe( + "That message already started. Your edit is back in the composer.", + ); + }); + + it("recovers changed text into an empty thread draft", async () => { + await waitForComposerDraftsLoaded(); + const threadKey = "env-exit:thread-changed-recover"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([]), runs); + setComposerDraftText(queuedEditDraftKey(threadKey, runId), "revised"); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + await flushComposerDrafts(); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe("revised"); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe( + "That message already started. Your edit is back in the composer.", + ); + }); + it("ends a changed edit when its run starts, without the thread's composer mounted", () => { const threadKey = "env-exit:thread-exit"; const runId = RunId.make("run-1"); From b7c7a7819a9bd144072cb8ea11b4896cf435473d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:58:59 -0500 Subject: [PATCH 61/86] fix(server): t3 project commands wait for a busy server's reply (#24090) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/cli/project.test.ts | 67 +++++++++++++++++++++++++++++ apps/server/src/cli/project.ts | 23 +++++----- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 40a7ab7113839..723f5254e7a2c 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -18,11 +18,14 @@ import { import * as NetService from "@t3tools/shared/Net"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; import * as Stream from "effect/Stream"; import { Command } from "effect/cli"; +import { FetchHttpClient } from "effect/http"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cli } from "../binCli.ts"; @@ -37,6 +40,10 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -121,6 +128,66 @@ it.effect("adds, renames, and removes projects through the V2 project CLI domain }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect.each(["snapshot", "mutation"] as const)( + "waits for a busy live server's %s response", + (slowRequest) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-live-project-cli-" }); + const baseDir = NodePath.join(root, "state"); + const workspaceRoot = NodePath.join(root, "workspace"); + yield* fs.makeDirectory(workspaceRoot); + yield* persistServerRuntimeState({ + path: NodePath.join(baseDir, "userdata", "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: 3_773, + }), + }); + const now = "2026-01-01T00:00:00.000Z"; + const requestReceived = Promise.withResolvers(); + const releaseResponse = Promise.withResolvers(); + let snapshotRequests = 0; + let mutations = 0; + const fetch: typeof globalThis.fetch = async (_input, init) => { + const isMutation = init?.method === "POST"; + if (isMutation) mutations++; + else snapshotRequests++; + if (slowRequest === "mutation" ? isMutation : !isMutation && snapshotRequests === 2) { + requestReceived.resolve(); + await releaseResponse.promise; + } + return Response.json( + isMutation + ? { + id: "live-project", + title: NodePath.basename(workspaceRoot), + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + } + : { projects: [], updatedAt: now }, + ); + }; + + const add = yield* runCli(["project", "add", workspaceRoot, "--base-dir", baseDir]).pipe( + Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.forkChild, + ); + yield* Effect.promise(() => requestReceived.promise); + yield* TestClock.adjust("2 seconds"); + releaseResponse.resolve(); + yield* Fiber.join(add); + + assert.equal(snapshotRequests, 2); + assert.equal(mutations, 1); + assert.deepEqual((yield* readProjects(baseDir)).projects, []); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); + const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixture")(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-lookup-" }); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index a5c8aa532ff3e..3d02f9c921fce 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -214,7 +214,8 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( Layer.provideMerge(SqlitePersistence.layerConfig), ); -const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); +const PROJECT_CLI_LIVE_SERVER_PROBE_TIMEOUT = Duration.seconds(1); +const PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT = Duration.seconds(10); const withProjectCliSessionToken = ( environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], run: (token: string) => Effect.Effect, @@ -228,9 +229,6 @@ const withProjectCliSessionToken = ( (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), ); -const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => - effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); - const makeLiveServerClient = (origin: string) => HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin, @@ -317,17 +315,14 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( }); const fetchLiveOrchestrationSnapshot = Effect.fn("project.fetchLiveOrchestrationSnapshot")( - function* (origin: string, bearerToken: string) { + function* (origin: string, bearerToken: string, _timeout?: Duration.Duration) { const client = yield* makeLiveServerClient(origin); return yield* client.projects.snapshot({ headers: { authorization: `Bearer ${bearerToken}` }, }); }, - (effect, _origin, _bearerToken) => - effect.pipe( - withProjectCliLiveServerTimeout, - Effect.mapError(projectCommandErrorFromLiveServerRequest), - ), + (effect, _origin, _bearerToken, timeout = PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT) => + effect.pipe(Effect.timeout(timeout), Effect.mapError(projectCommandErrorFromLiveServerRequest)), ); const dispatchLiveOrchestrationCommand = Effect.fn("project.dispatchLiveOrchestrationCommand")( @@ -340,7 +335,7 @@ const dispatchLiveOrchestrationCommand = Effect.fn("project.dispatchLiveOrchestr }, (effect, _origin, _bearerToken, _command) => effect.pipe( - withProjectCliLiveServerTimeout, + Effect.timeout(PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT), Effect.mapError(projectCommandErrorFromLiveServerRequest), ), ); @@ -361,7 +356,11 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu } const attempt = withProjectCliSessionToken(environmentAuth, (token) => - fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( + fetchLiveOrchestrationSnapshot( + runtimeState.value.origin, + token, + PROJECT_CLI_LIVE_SERVER_PROBE_TIMEOUT, + ).pipe( Effect.as({ origin: runtimeState.value.origin, }), From 9d38d68d0437da92966866a789164a135af5c050 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:59:21 -0500 Subject: [PATCH 62/86] fix(web): wait for fresh diff pages in review guides (#23225) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../guide/useGuideDiffFiles.browser.test.tsx | 70 +++++++++++++++++++ .../pullRequest/guide/useGuideDiffFiles.ts | 2 +- .../client-runtime/src/state/runtime.test.ts | 29 ++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx diff --git a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx new file mode 100644 index 0000000000000..cdb7d4099e5c7 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx @@ -0,0 +1,70 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, ProjectId, type PullRequestDiffResult } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../../test/render"; + +const state = vi.hoisted(() => ({ + pages: new Map>>(), +})); +vi.mock("~/state/pullRequests", () => ({ + pullRequestEnvironment: { + diff: ({ input }: { input: { cursor?: string } }) => state.pages.get(input.cursor ?? null)!, + }, +})); + +import { useGuideDiffFiles } from "./useGuideDiffFiles"; + +function Probe({ wantedPaths }: { wantedPaths: string[] }) { + const diff = useGuideDiffFiles({ + environmentId: EnvironmentId.make("test"), + reference: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + headSha: "new-head", + wantedPaths, + }); + return ( + + {JSON.stringify({ + loading: diff.loading, + files: [...diff.files].map(([path, file]) => [path, file.additionLines.join("")]), + })} + + ); +} + +function page(path: string, value: string, nextCursor: string | null): PullRequestDiffResult { + return { + patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+${value}\n`, + truncated: false, + nextCursor, + }; +} + +it.each([ + { name: "contains the requested file", path: "wanted.ts", cursor: null }, + { name: "ends before a newly added file", path: "other.ts", cursor: null }, + { name: "has an obsolete next cursor", path: "other.ts", cursor: "old-next" }, +])("waits for a cached page that $name to settle", async ({ path, cursor }) => { + state.pages.clear(); + await using stack = new AsyncDisposableStack(); + const registry = stack.adopt(AtomRegistry.make(), (value) => value.dispose()); + const first = Atom.make>( + AsyncResult.success(page(path, "STALE", cursor), { waiting: true }), + ); + state.pages.set(null, first); + state.pages.set( + "old-next", + Atom.make>(AsyncResult.initial(true)), + ); + await using screen = await render( + + + , + ); + const result = () => JSON.parse(screen.container.querySelector("output")!.textContent!); + expect(result()).toEqual({ loading: true, files: [] }); + + await act(() => registry.set(first, AsyncResult.success(page("wanted.ts", "FRESH", null)))); + expect(result()).toEqual({ loading: false, files: [["wanted.ts", "FRESH"]] }); +}); diff --git a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts index c905488949cff..21765c68d18e8 100644 --- a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts @@ -51,7 +51,7 @@ export function useGuideDiffFiles({ ); const data = query.data; - if (data !== null && !current.done && current.folded !== data) { + if (data !== null && !query.isPending && !current.done && current.folded !== data) { const parsed = getRenderablePatch( data.patch, `pull-request-guide:${key}:${current.cursor ?? "first"}`, diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 963b3a7bfdeb7..519ccd025b070 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1212,6 +1212,35 @@ describe("runtime command runner", () => { }); describe("persisted environment queries", () => { + it.effect("keeps an in-memory cached page pending until revalidation settles", () => + Effect.scoped( + Effect.gen(function* () { + const refreshStarted = Latch.makeUnsafe(); + const finishRefresh = Latch.makeUnsafe(); + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.suspend(() => { + if (++executions === 1) return Effect.succeed("cached"); + refreshStarted.openUnsafe(); + return finishRefresh.await.pipe(Effect.as("fresh")); + }), + { persistence: { load: () => Effect.succeedNone, save: () => Effect.void } }, + ); + const registry = yield* mountEnvironmentQuery(harness.atom); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("cached"); + registry.refresh(harness.atom); + yield* refreshStarted.await; + expect(registry.get(harness.atom)).toMatchObject({ + _tag: "Success", + value: "cached", + waiting: true, + }); + finishRefresh.openUnsafe(); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("fresh"); + }), + ), + ); + it.effect( "restores cached data in a new registry while offline, then refreshes on reconnect", () => From 99ff58abe8507acc4a97abb89c6854c4e74fbd08 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:59:44 -0500 Subject: [PATCH 63/86] fix(server): Antigravity stops retrying session startup while signed out (#24939) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../Adapters/AntigravityAdapterV2.test.ts | 59 +++++++++++++++++++ .../Adapters/AntigravityAdapterV2.ts | 2 + .../ProviderTurnStartService.test.ts | 28 +++++++++ .../ProviderTurnStartService.ts | 10 +++- packages/provider-acp/src/server/adapter.ts | 2 + .../src/server/ProviderAdapter.ts | 1 + 6 files changed, 101 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 90226cc68bd2a..0f5670f1d2de0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -24,6 +24,7 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpSchema from "effect-acp/compat"; +import * as EffectAcpErrors from "effect-acp/errors"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -32,6 +33,7 @@ import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSession import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { normalizeAntigravitySessionUpdate } from "../../provider/acp/AntigravityProtocol.ts"; +import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../../provider/antigravityAuthSupport.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { @@ -126,6 +128,63 @@ const layerSession = Layer.mergeAll( TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), ); +describe("AntigravityAdapterV2 session open", () => { + it.effect.each([ + { + name: "browser sign-in", + cause: new EffectAcpErrors.AcpTransportError({ + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + cause: undefined, + }), + requiresSignIn: true, + }, + { + name: "ACP authentication required", + cause: EffectAcpErrors.AcpRequestError.authRequired(), + requiresSignIn: true, + }, + { + name: "transport failure", + cause: new EffectAcpErrors.AcpTransportError({ + detail: "Connection closed", + cause: undefined, + }), + requiresSignIn: undefined, + }, + ] as const)("classifies $name during session open", ({ cause, requiresSignIn }) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("antigravity-sign-in-test"); + const adapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: () => Effect.fail(cause), + withProcess: (_stop, task) => task, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + defaultModel: Effect.succeed(undefined), + }); + const threadId = ThreadId.make("thread-antigravity-sign-in"); + const error = yield* adapter + .openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-antigravity-sign-in"), + modelSelection: { instanceId, model: "gemini-test-low" }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }), + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ProviderAdapterOpenSessionError"); + assert.equal( + error._tag === "ProviderAdapterOpenSessionError" && error.requiresSignIn, + requiresSignIn, + ); + }).pipe(Effect.provide(layerSession), Effect.scoped), + ); +}); + describe("AntigravityAdapterV2 client file system", () => { it.effect("serves confined reads and leaves edits to the agent's diff tools", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 4d06da900f4a8..9ccaca83b542f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -34,6 +34,7 @@ import { makeAntigravityUserInputResponse, normalizeAntigravityToolCall, } from "../../provider/acp/AntigravityProtocol.ts"; +import { isAntigravitySignInRequiredError } from "../../provider/antigravityAuthSupport.ts"; import { AcpProviderCapabilitiesV2, makeAcpAdapterV2, @@ -238,6 +239,7 @@ export function makeAntigravityAcpAdapterFlavor( extractSubagentUpdate: extractAntigravitySubagentUpdate, supportsImagePrompts: true, supportsCompaction: true, + requiresSignIn: isAntigravitySignInRequiredError, }; } diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 7a922d79c7317..beaf7961e3782 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1304,6 +1304,34 @@ effectIt.effect("leaves the run starting when a session-open failure will be ret }), ); +effectIt.effect("fails the run without a retry when the provider needs sign-in", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + openFailure: new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: ProviderDriverKind.make("antigravity"), + providerSessionId: ProviderSessionId.make("new-provider-session"), + requiresSignIn: true, + }), + }); + + yield* harness.startWithRetry; + + expect(harness.open).toHaveBeenCalledOnce(); + expect(harness.projection().runs.at(-1)?.status).toBe("failed"); + expect(harness.projection().turnItems).toMatchObject([ + { + type: "error", + status: "failed", + failure: { + class: "provider_error", + message: "Failed to open antigravity provider session new-provider-session.", + }, + }, + ]); + }), +); + effectIt.effect("does not terminalize a provider-session open interruption", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ text: "Continue", interruptOpen: true }); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 94ba406b106f5..fad06732fb48e 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -92,6 +92,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError Effect.Effect, EffectAcpErrors.AcpError>; readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; + readonly requiresSignIn?: (cause: unknown) => boolean; /** * Runs before attachment paths, T3 instructions and native slash-command detection, * so a rewrite can turn the user's text into a native command. @@ -8555,6 +8556,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( new ProviderAdapter.ProviderAdapterOpenSessionError({ driver, providerSessionId: input.providerSessionId, + ...(flavor.requiresSignIn?.(cause) === true ? { requiresSignIn: true } : {}), cause, }), ), diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 9dc7ec1f62285..e168512736c7c 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -183,6 +183,7 @@ export class ProviderAdapterOpenSessionError extends Schema.TaggedError Date: Thu, 29 Oct 2026 13:00:06 -0500 Subject: [PATCH 64/86] fix(web): create tasks on a permitted connected environment (#23244) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../ScheduledTasksSettings.browser.test.tsx | 79 ++++++++++++++++--- .../settings/ScheduledTasksSettings.tsx | 24 ++++-- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e42242f01093e..124a27c7b11f4 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -1,3 +1,5 @@ +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; import { DEFAULT_UNIFIED_SETTINGS, EnvironmentId, @@ -10,7 +12,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import { render } from "../../../test/render"; +import { act, render } from "../../../test/render"; import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; const environmentId = EnvironmentId.make("laptop"); @@ -38,7 +40,23 @@ const task: ScheduledTask = { runCount: 0, }; -const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true })); +const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false })); +const secondaryId = EnvironmentId.make("secondary"); +const permissions = new Map>(); + +function setup() { + state.connected = true; + state.tasksLoaded = true; + state.secondary = false; + permissions.clear(); + permissions.set(environmentId, Atom.make(true)); + permissions.set(secondaryId, Atom.make(true)); + permissions.set("", Atom.make(false)); +} + +function secondaryEnvironment() { + return { ...environment(), environmentId: secondaryId, label: "Secondary" }; +} function environment() { return { @@ -54,8 +72,12 @@ vi.mock(import("./SettingsScopeContext"), async (original) => { const entry = environment(); return { scope: { kind: "all", label: "All", members: [], environmentIds: [environmentId] }, - environments: [entry], - connectedEnvironments: state.connected ? [entry] : [], + environments: state.secondary ? [entry, secondaryEnvironment()] : [entry], + connectedEnvironments: state.connected + ? state.secondary + ? [entry, secondaryEnvironment()] + : [entry] + : [], environment: entry, target: null, targets: [], @@ -68,15 +90,14 @@ vi.mock(import("./SettingsScopeContext"), async (original) => { }); vi.mock(import("../../state/server"), async (original) => { const actual = await original(); - const { Atom } = await import("effect/reactivity"); - const allowed = Atom.make(true); + return { ...actual, serverEnvironment: { ...actual.serverEnvironment, upsertScheduledTask: { ...actual.serverEnvironment.upsertScheduledTask, - permissionAtom: () => allowed, + permissionAtom: (id: string | null) => permissions.get(id ?? "")!, }, }, }; @@ -93,7 +114,8 @@ vi.mock(import("../../state/query"), async (original) => ({ })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironment: (() => environment()) as never, + useEnvironment: ((id: EnvironmentId) => + id === secondaryId ? secondaryEnvironment() : environment()) as never, })); vi.mock(import("../../state/entities"), async (original) => ({ ...(await original()), @@ -105,9 +127,44 @@ vi.mock(import("../../hooks/useSettings"), async (original) => ({ })); describe("ScheduledTasksSettings", () => { + it("creates on a permitted secondary and reacts to permission changes", async () => { + setup(); + state.secondary = true; + const registry = AtomRegistry.make(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + registry.set(permissions.get(environmentId)!, false); + await using screen = await render( + + + , + ); + const create = screen.getByRole("button", { name: "New task" }); + await expect.element(create).toBeEnabled(); + await act(() => registry.set(permissions.get(secondaryId)!, false)); + await expect.element(create).toBeDisabled(); + await act(() => registry.set(permissions.get(secondaryId)!, true)); + await expect.element(create).toBeEnabled(); + await create.click(); + await expect.element(page.getByLabelText("Runs on")).toHaveTextContent("Secondary"); + }); + + it("keeps the permitted representative as the creation target", async () => { + setup(); + state.secondary = true; + await using screen = await render(); + await screen.getByRole("button", { name: "New task" }).click(); + await expect.element(page.getByLabelText("Runs on")).toHaveTextContent("Laptop"); + }); + + it("disables creation without a connected target", async () => { + setup(); + state.connected = false; + await using screen = await render(); + await expect.element(screen.getByRole("button", { name: "New task" })).toBeDisabled(); + }); + it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => { - state.connected = true; - state.tasksLoaded = true; + setup(); await using screen = await render(); const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" }); await expect.element(name).toHaveValue("Review issues"); @@ -124,7 +181,7 @@ describe("ScheduledTasksSettings", () => { }); it("keeps an editor the user opened when the linked task loads later", async () => { - state.connected = true; + setup(); state.tasksLoaded = false; await using screen = await render(); await userEvent.click(page.getByRole("button", { name: "New task" })); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 1f64fe501af1d..e027438a6abdb 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -396,9 +396,18 @@ export function ScheduledTasksSettings(target: { replace: true, }); }; - const canCreate = useAtomValue( - serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null), + const createPermissions = useAtomValue( + scheduledTaskCreatePermissionsAtom( + environmentIdsKey(connectedEnvironments.map((entry) => entry.environmentId)), + ), ); + const permittedEnvironments = connectedEnvironments.filter( + (_entry, index) => createPermissions[index], + ); + const creationEnvironment = + permittedEnvironments.find( + (entry) => entry.environmentId === defaultEnvironment?.environmentId, + ) ?? permittedEnvironments[0]; return ( - defaultEnvironment && - setEditor({ environmentId: defaultEnvironment.environmentId, task: null }) + creationEnvironment && + setEditor({ environmentId: creationEnvironment.environmentId, task: null }) } > @@ -464,6 +473,11 @@ export function ScheduledTasksSettings(target: { ); } +const scheduledTaskCreatePermissionsAtom = environmentAtomList( + "web-scheduled-task-create-permissions", + serverEnvironment.upsertScheduledTask.permissionAtom, +); + const scheduledTasksListAtom = environmentAtomList("web-scheduled-tasks-list", (environmentId) => serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), ); From fa2a2dc7749eded7352c04f72c7c48167e6f2b78 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 13:00:29 -0500 Subject: [PATCH 65/86] fix(mobile): recognize this thread's main checkout (#23764) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../git/GitBranchesSheet.occupancy.test.tsx | 100 ++++++++++++++++++ .../features/threads/git/GitBranchesSheet.tsx | 4 +- apps/mobile/vite.config.ts | 1 + 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx new file mode 100644 index 0000000000000..7667b0c793fc4 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -0,0 +1,100 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const checkout = vi.hoisted(() => ({ + projectRoot: "", + worktreePath: null as string | null, + branches: [] as Array<{ name: string; worktreePath: string | null }>, +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "android" }, + View: ({ children }: { children: ReactNode }) =>
{children}
, + ScrollView: ({ children }: { children: ReactNode }) =>
{children}
, + Pressable: ({ children, disabled }: { children: ReactNode; disabled: boolean }) => ( + + ), + useWindowDimensions: () => ({ height: 800 }), +})); +vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); +vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock("../../../components/ui/AndroidScreenHeader", () => ({ AndroidSheetHeader: () => null })); +vi.mock("../../../components/ui/SheetHeader", () => ({ SheetHeader: () => null })); +vi.mock("../../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../../components/ui/AppText", () => ({ + AppText: ({ children }: { children: ReactNode }) => {children}, + AppTextInput: () => null, +})); +vi.mock("./gitSheetComponents", () => ({ SheetActionButton: () => null })); +vi.mock("../../../state/use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: { environmentId: "environment-1", worktreePath: checkout.worktreePath }, + selectedThreadProject: { workspaceRoot: checkout.projectRoot }, + }), +})); +vi.mock("../../../state/use-thread-detail", () => ({ + useSelectedThreadWorktreePath: () => checkout.worktreePath, +})); +vi.mock("../../../state/use-selected-thread-git-state", () => ({ + useSelectedThreadGitState: () => ({ + selectedThreadBranches: checkout.branches, + selectedThreadBranchesLoading: false, + gitOperationLabel: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-actions", () => ({ + useSelectedThreadGitActions: () => ({ canChangeThreadBranch: true }), +})); +vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); + +import { GitBranchesSheet } from "./GitBranchesSheet"; + +it("recognizes both the main checkout and a linked worktree as this thread's checkout", () => { + const directory = "/repo"; + const linked = "/repo-linked"; + checkout.projectRoot = directory; + checkout.branches = [ + { name: "feature/available", worktreePath: null }, + { name: "feature/linked", worktreePath: linked }, + { name: "main", worktreePath: directory }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + const root = createRoot(container); + const rows = () => { + act(() => { + root.render( + , + ); + }); + return [...container.querySelectorAll("button")].map((button) => ({ + name: button.querySelectorAll("span")[0]?.textContent, + subtitle: button.querySelectorAll("span")[1]?.textContent, + disabled: button.disabled, + })); + }; + try { + checkout.worktreePath = null; + expect(rows()).toEqual([ + { name: "feature/available", subtitle: "Local branch", disabled: false }, + { name: "feature/linked", subtitle: "Checked out in another worktree", disabled: true }, + { name: "main", subtitle: "Checked out in this thread", disabled: false }, + ]); + + checkout.worktreePath = linked; + expect(rows()).toEqual([ + { name: "feature/available", subtitle: "Local branch", disabled: false }, + { name: "feature/linked", subtitle: "Checked out in this thread", disabled: false }, + { name: "main", subtitle: "Checked out in another worktree", disabled: true }, + ]); + } finally { + act(() => root.unmount()); + } +}); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 4075a49e9774a..dcaf96818bfe0 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -28,7 +28,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); - const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const { canChangeThreadBranch } = gitActions; @@ -43,7 +43,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; - const currentWorktreePath = selectedThreadWorktreePath; + const currentWorktreePath = selectedThreadCwd; const availableBranches = gitState.selectedThreadBranches; const branchesLoading = gitState.selectedThreadBranchesLoading; const busy = gitState.gitOperationLabel !== null; diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6a92a025778d9..9ddb84efbc832 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/threads/git/GitBranchesSheet.occupancy.test.tsx", "src/features/files/useFileTreeEntries.test.tsx", "src/features/projects/AddProjectScreen.test.ts", "src/lib/useMediaVideoPlayback.test.tsx", From 06ded0b60049cf213c62bf34b0b2ad85efd8d1f4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 13:00:53 -0500 Subject: [PATCH 66/86] fix(web): keep usage hubs that share a host (#23767) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../AddUsageLimitSourceDialog.logic.test.ts | 61 +++++++++++++++++++ .../AddUsageLimitSourceDialog.logic.ts | 24 ++++++++ .../settings/AddUsageLimitSourceDialog.tsx | 19 ++---- .../settings/UsageProviderSettings.tsx | 1 + 4 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts create mode 100644 apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts new file mode 100644 index 0000000000000..33192bbaab71f --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts @@ -0,0 +1,61 @@ +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { describe, expect, it } from "vite-plus/test"; + +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; + +describe("adding usage limit sources", () => { + it.each([ + ["http://proxy.test:8318/hub-a", "http://proxy.test:8318/hub-b"], + ["localhost:8318", "localhost:9318"], + ])("retains saved configurations for %s and %s", (firstUrl, secondUrl) => { + let settings: ServerSettings = DEFAULT_SERVER_SETTINGS; + const sources = [ + [firstUrl, "First hub", "first-key"], + [secondUrl, "Second hub", "second-key"], + [`${secondUrl}/extra`, "Third hub", "third-key"], + ] as const; + for (const [url, label, managementKey] of sources) { + const id = sourceIdFromUrl(url, settings.usageLimitSources); + settings = applyServerSettingsPatch(settings, { + usageLimitSources: { + [id]: { kind: "cliproxy", url, label, managementKey, enabled: true }, + }, + }); + } + expect(Object.values(settings.usageLimitSources)).toEqual( + sources.map(([url, label, managementKey]) => ({ + kind: "cliproxy", + url, + label, + managementKey, + enabled: true, + })), + ); + }); + + it("updates a saved hub when its URL is added again", () => { + let settings: ServerSettings = DEFAULT_SERVER_SETTINGS; + for (const managementKey of ["old-key", "new-key"]) { + const url = "http://proxy.test:8318/hub-a"; + settings = applyServerSettingsPatch(settings, { + usageLimitSources: { + [sourceIdFromUrl(url, settings.usageLimitSources)]: { + kind: "cliproxy", + url, + managementKey, + enabled: true, + }, + }, + }); + } + expect(Object.values(settings.usageLimitSources)).toEqual([ + { + kind: "cliproxy", + url: "http://proxy.test:8318/hub-a", + managementKey: "new-key", + enabled: true, + }, + ]); + }); +}); diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts new file mode 100644 index 0000000000000..67ccabdefefc9 --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts @@ -0,0 +1,24 @@ +import { type UnifiedSettings, UsageLimitSourceId } from "@t3tools/contracts"; + +export function sourceIdFromUrl( + url: string, + sources: UnifiedSettings["usageLimitSources"], +): UsageLimitSourceId { + const saved = Object.entries(sources).find(([, source]) => source.url === url); + if (saved) return UsageLimitSourceId.make(saved[0]); + let host = url; + try { + host = new URL(url).host; + } catch {} + const slug = host + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + // Settings merge by ID, so adding a different hub must not replace an existing entry. + const base = `cliproxy-${slug || "hub"}`; + let id = base; + for (let suffix = 2; id in sources; suffix += 1) { + id = `${base}-${suffix}`; + } + return UsageLimitSourceId.make(id); +} diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index cc150016d73ce..e3abd8b951067 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -1,4 +1,4 @@ -import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; +import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; @@ -13,32 +13,23 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; -function sourceIdFromUrl(url: string): UsageLimitSourceId { - let host = url; - try { - host = new URL(url).host; - } catch {} - const slug = host - .toLowerCase() - .replace(/[^a-z0-9.-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); -} - /** Send the management key once to server secret storage; settings retain only a redaction marker. */ export function AddUsageLimitSourceDialog({ open, onOpenChange, environmentId, environmentLabel, + sources, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: EnvironmentId; readonly environmentLabel: string; + readonly sources: UnifiedSettings["usageLimitSources"]; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); const [label, setLabel] = useState(""); @@ -55,7 +46,7 @@ export function AddUsageLimitSourceDialog({ const save = () => { if (!canSave) return; - const id = sourceIdFromUrl(trimmedUrl); + const id = sourceIdFromUrl(trimmedUrl, sources); updateSettings({ usageLimitSources: { [id]: { diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index bab7ddd9c52a6..f238850ae04d0 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -121,6 +121,7 @@ export function UsageProviderSettings({ onOpenChange={setAdding} environmentId={environmentId} environmentLabel={environmentLabel} + sources={sources} /> ) : null} From f8646e00c1528ef2aaa161a5c246119a6e5f7780 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:01:16 +0000 Subject: [PATCH 67/86] fix(github): read text and mixed-case pull request templates (#25025) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../gitHubChangeRequestTemplate.test.ts | 38 ++++++++++ .../src/server/gitHubChangeRequestTemplate.ts | 72 ++++++++++++++++--- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts index 58b094063ecba..29bf922020d0d 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts @@ -227,3 +227,41 @@ it.effect("bounds template reads and marks truncated content", () => }), ), ); + +it.effect.each([ + ".github/pull_request_template.txt", + "PULL_REQUEST_TEMPLATE.TXT", + "docs/PuLl_ReQuEsT_TeMpLaTe.Md", + ".github/PULL_REQUEST_TEMPLATE/template.TxT", +])("reads committed text and mixed-case template $0 from a subdirectory", (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* runGit(cwd, ["config", "grep.patternType", "fixed"]); + yield* runGit(cwd, ["config", "color.grep", "always"]); + yield* writeTemplate(cwd, relativePath, "committed template"); + yield* writeTemplate(cwd, "nested/README.md", "project"); + for (let index = 0; index < 600; index += 1) { + yield* writeTemplate(cwd, `docs/${index}-${"a".repeat(180)}.md`, "guide"); + } + yield* commitTemplates(cwd); + yield* writeTemplate(cwd, relativePath, "uncommitted template"); + + assert.strictEqual( + Option.getOrUndefined(yield* detectTemplate(path.join(cwd, "nested"))), + "committed template", + ); + }), + ), +); + +it.effect("does not guess between Markdown and text directory templates", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "markdown"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.TXT", "text"); + yield* commitTemplates(cwd); + assert.isTrue(Option.isNone(yield* detectTemplate(cwd))); + }), + ), +); diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts index c6af80389d1f7..9edbccdd504dc 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts @@ -11,10 +11,16 @@ const TRUNCATION_MARKER = "[truncated]"; const TEMPLATE_PATHS = [ ".github/pull_request_template.md", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/pull_request_template.txt", + ".github/PULL_REQUEST_TEMPLATE.txt", "pull_request_template.md", "PULL_REQUEST_TEMPLATE.md", + "pull_request_template.txt", + "PULL_REQUEST_TEMPLATE.txt", "docs/pull_request_template.md", "docs/PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.txt", + "docs/PULL_REQUEST_TEMPLATE.txt", ] as const; const TEMPLATE_DIRECTORIES = [ @@ -23,8 +29,6 @@ const TEMPLATE_DIRECTORIES = [ "docs/PULL_REQUEST_TEMPLATE", ] as const; -const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; - type ExecuteGit = SourceControlHost.SourceControlHost["Service"]["git"]["execute"]; interface TemplateTreeEntry { @@ -105,7 +109,7 @@ const readTemplateDirectory = Effect.fn("gitHubChangeRequestTemplate.readTemplat return false; } const relativePath = entry.path.slice(prefix.length); - return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md"); + return !relativePath.includes("/") && /\.(?:md|txt)$/i.test(relativePath); }); const templates: string[] = []; @@ -132,10 +136,50 @@ export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* ) { return yield* Effect.gen(function* () { // Read committed regular blobs; worktree paths can become symlinks between validation and open. + // ls-tree does not support case-insensitive pathspecs; grep discovers only matching names. + const paths = yield* executeGit({ + operation: "gitHubChangeRequestTemplate.findTemplatePaths", + cwd, + args: [ + "grep", + "-G", + "--no-color", + "-l", + "-z", + "--full-name", + "--no-recurse-submodules", + "-e", + "^", + treeish, + "--", + ...TEMPLATE_PATHS.map((path) => `:(top,icase,literal)${path}`), + ], + maxOutputBytes: TREE_LIST_MAX_BYTES, + }).pipe( + Effect.map((result) => + result.stdoutTruncated + ? [] + : result.stdout + .split("\0") + .filter((path) => path.startsWith(`${treeish}:`)) + .map((path) => path.slice(treeish.length + 1)), + ), + // grep exits with status 1 when no nonempty templates match. + Effect.orElseSucceed(() => []), + ); const result = yield* executeGit({ operation: "gitHubChangeRequestTemplate.listTemplates", cwd, - args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS], + args: [ + "ls-tree", + "-r", + "-z", + "--full-tree", + treeish, + "--", + ...new Set([...TEMPLATE_PATHS, ...paths]), + ...TEMPLATE_DIRECTORIES, + ], maxOutputBytes: TREE_LIST_MAX_BYTES, appendTruncationMarker: true, }); @@ -144,12 +188,20 @@ export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* } const entries = parseTemplateTreeEntries(result.stdout); - const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); - for (const templatePath of TEMPLATE_PATHS) { - const entry = entriesByPath.get(templatePath); - if (!entry) { - continue; - } + const candidates = [".github/", "", "docs/"].flatMap((location) => { + const paths = TEMPLATE_PATHS.filter( + (path) => path.slice(0, path.lastIndexOf("/") + 1) === location, + ); + return [ + ...paths.flatMap((path) => entries.filter((entry) => entry.path === path)), + ...entries.filter( + (entry) => + !paths.some((path) => path === entry.path) && + paths.some((path) => path.toLowerCase() === entry.path.toLowerCase()), + ), + ]; + }); + for (const entry of candidates) { const template = yield* readTemplateBlob({ cwd, executeGit, entry }); if (Option.isSome(template)) { return template; From de2b1d33582edee2604567dc572fde4c3b906450 Mon Sep 17 00:00:00 2001 From: Stevan Borus Date: Thu, 29 Oct 2026 19:01:27 +0100 Subject: [PATCH 68/86] fix(web): keep HTML prototype selection rings visible (#23745) Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/provider/agentContextBudget.json | 2 +- .../BrowserDocumentFrame.browser.test.tsx | 107 ++++++++++++++++++ packages/shared/src/htmlRender.ts | 3 +- 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index d5496ea8cfc57..1711e148d4ca4 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 45907 + "mcpToolListJsonBytes": 46007 } diff --git a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx index 9785a6764b2b6..3915ecce9a4ca 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx @@ -1,3 +1,4 @@ +import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender"; import { describe, expect, it, vi } from "vite-plus/test"; import "~/index.css"; @@ -34,3 +35,109 @@ describe("HtmlRenderDocument", () => { expect(onContentHeight).toHaveBeenCalledTimes(1); }); }); + +it("keeps the first and last selected card rings inside the fitted renderer frame", async () => { + const html = injectHtmlRenderBootstrap(`
+ + + +
`); + const src = URL.createObjectURL(new Blob([html], { type: "text/html" })); + using _url = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + const container = document.createElement("div"); + container.style.width = "728px"; + const reports = new Map< + string, + { + selected: boolean; + ring: number; + left: number; + right: number; + top: number; + bottom: number; + pageHeight: number; + viewportHeight: number; + viewportWidth: number; + scrollWidth: number; + } + >(); + const messages = new AbortController(); + using _messages = { [Symbol.dispose]: () => messages.abort() }; + window.addEventListener( + "message", + (event) => { + const frame = container.querySelector("iframe"); + if (event.source === frame?.contentWindow && event.data?.type === "selected-card-bounds") { + reports.set(event.data.id, event.data); + } + }, + { signal: messages.signal }, + ); + const contentHeight = vi.fn((height: number) => { + container.style.setProperty("--content-height", `${height}px`); + }); + await using screen = await render( + <> + + + , + { container }, + ); + + await vi.waitFor(() => expect(contentHeight).toHaveBeenCalled()); + const frame = screen.getByTitle("Edge cards").element() as HTMLIFrameElement; + await vi.waitFor(() => { + expect(frame.getBoundingClientRect().height).toBe(contentHeight.mock.lastCall?.[0]); + }); + + for (const id of ["first", "last"]) { + frame.contentWindow?.postMessage({ type: "select-card", id }, "*"); + await vi.waitFor(() => expect(reports.get(id)?.selected).toBe(true)); + const bounds = reports.get(id)!; + expect(bounds.ring).toBe(5); + expect(bounds.viewportWidth).toBe(728); + expect(bounds.pageHeight).toBe(contentHeight.mock.lastCall?.[0]); + expect(bounds.viewportHeight).toBe(bounds.pageHeight); + expect(bounds.left).toBeGreaterThanOrEqual(0); + expect(bounds.top).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth); + expect(bounds.bottom).toBeLessThanOrEqual(bounds.viewportHeight); + expect(bounds.scrollWidth).toBeLessThanOrEqual(bounds.viewportWidth); + } +}); diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index 572441ffe5366..12af0f39336b2 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -232,6 +232,7 @@ export const HTML_RENDER_THEME_GUIDE = [ export const HTML_RENDER_LAYOUT_GUIDE = [ `Frame width is reply width (${HTML_RENDER_COLUMN_WIDTH}px desktop, about 360px phones), on the thread background.`, "Leave html, body, and the outer element with no background color, no outer card, border, banner title, or side padding. This wins over any rule that sets a page background.", + "T3 adds an overridable 8px html gutter for selection rings and shadows. Use fluid widths within it.", "A box that needs its own background gets 16px padding and var(--radius) corners. Give charts fixed pixel heights.", "Let content set the height. Do not use vh units or height:100% on html or body. Both are rejected or loop.", ].join(" "); @@ -353,7 +354,7 @@ export function htmlRenderThemeMessage(theme: HtmlRenderTheme) { } const BASE_CSS = - "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + + "html{box-sizing:border-box;padding:8px;background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}" + // Focus outlines paint inside the element, so the frame edge never clips them. ":where(:focus-visible){outline:2px solid var(--ring)}:focus-visible{outline-width:2px!important;outline-offset:-2px!important}" + From 12bd9d6a618ad0f2eadf34d31f6c428a12a8d150 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:01:49 +0000 Subject: [PATCH 69/86] fix(mobile): list pull request repositories once per environment (#25219) Co-authored-by: Tristan Knight <18444302+tris203@users.noreply.github.com> --- .../pull-requests/PullRequestsScreen.tsx | 13 +++-- .../pull-requests/pullRequestList.test.ts | 49 +++++++++++++++++++ .../features/pull-requests/pullRequestList.ts | 29 +++++++++++ .../pullRequestProjectFilter.logic.test.ts | 2 +- .../pullRequest/usePullRequestListView.ts | 2 +- packages/client-runtime/package.json | 4 ++ .../src/pullRequestProjectFilter.ts | 13 ++--- 7 files changed, 100 insertions(+), 12 deletions(-) rename apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts => packages/client-runtime/src/pullRequestProjectFilter.ts (82%) diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index b5fb14b198ab4..6c675219dc943 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -44,6 +44,7 @@ import { scopedPullRequestListKey as pullRequestKey, type EnvironmentPullRequestListEntry, withoutScratchProjectScope, + pullRequestProjectChoices, } from "./pullRequestList"; import { useDebouncedValue } from "../../state/queries"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; @@ -221,10 +222,14 @@ export function PullRequestsScreen( } const labelColors = knownFacets.environmentId === environmentId ? knownFacets.labelColors : facets.labelColors; - const projects = allProjects.filter( - (project) => - !project.isScratch && (environmentId === null || project.environmentId === environmentId), - ); + const projects = pullRequestProjectChoices(allProjects, { + environmentId, + controls, + environmentLabels: new Map( + environments.map((item) => [item.environmentId, item.environmentLabel]), + ), + servesPullRequests: (id) => configs.get(id)?.environment.capabilities.pullRequests === true, + }); const filters = usePullRequestListFilters({ environments: environments.map((item) => ({ id: String(item.environmentId), diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index f7b1a7a1bf832..b6f47b6533ee2 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, + ProjectId, SourceControlProviderKind, type PullRequestListEntry, type PullRequestListResult, @@ -26,6 +27,7 @@ import { withListedPullRequests, withShownPullRequestStats, withoutScratchProjectScope, + pullRequestProjectChoices, } from "./pullRequestList"; function entry(number: number, patch: Partial = {}): PullRequestListEntry { @@ -402,6 +404,53 @@ describe("pullRequestFilterChoices", () => { }); }); +describe("pullRequestProjectChoices", () => { + const laptop = EnvironmentId.make("env-laptop"); + const desktop = EnvironmentId.make("env-desktop"); + const project = (id: string, environmentId: EnvironmentId) => ({ + id: ProjectId.make(id), + environmentId, + title: "t3code", + workspaceRoot: `/work/${id}`, + repositoryIdentity: { canonicalKey: "github.com/pingdotgg/t3code" }, + }); + const projects = [project("main", laptop), project("worktree", laptop), project("main", desktop)]; + const choices = ( + servesPullRequests: (environmentId: EnvironmentId) => boolean, + scope: { projectId?: string; projectEnvironmentId?: string } = {}, + ) => + pullRequestProjectChoices(projects, { + environmentId: null, + controls: { ...DEFAULT_PR_LIST_CONTROLS, ...scope }, + environmentLabels: new Map([ + [laptop, "laptop"], + [desktop, "desktop"], + ]), + servesPullRequests, + }).map(({ id, environmentId, title }) => ({ id, environmentId, title })); + + it("lists each repository once per environment that serves pull requests", () => { + expect(choices(() => true)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code · desktop" }, + { id: "main", environmentId: laptop, title: "t3code · laptop" }, + ]); + expect(choices((environmentId) => environmentId === desktop)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code" }, + ]); + }); + + it("keeps the saved scope when its environment stops serving or has not loaded", () => { + const scope = { projectId: "worktree", projectEnvironmentId: laptop }; + expect(choices((environmentId) => environmentId === desktop, scope)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code · desktop" }, + { id: "worktree", environmentId: laptop, title: "t3code · laptop" }, + ]); + expect(choices(() => false, scope)).toEqual([ + { id: "worktree", environmentId: laptop, title: "t3code" }, + ]); + }); +}); + describe("shown PR diff stats", () => { const cups = EnvironmentId.make("cups"); const stat = (number: number, additions: number, deletions: number) => ({ diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index fcb41ac8ec861..674d3b0e24fe3 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -1,3 +1,4 @@ +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query"; import { ProjectId, @@ -34,6 +35,34 @@ export function withoutScratchProjectScope( : controls; } +type FilterProject = Parameters[0][number]; + +export function pullRequestProjectChoices< + Project extends FilterProject & { readonly isScratch?: true }, +>( + projects: readonly Project[], + input: { + readonly environmentId: string | null; + readonly controls: PullRequestListControls; + readonly environmentLabels: ReadonlyMap; + readonly servesPullRequests: (environmentId: EnvironmentId) => boolean; + }, +) { + const isSelected = (project: Project) => + project.id === input.controls.projectId && + project.environmentId === input.controls.projectEnvironmentId; + return pullRequestFilterProjects( + projects.filter( + (project) => + !project.isScratch && + (input.environmentId === null || project.environmentId === input.environmentId) && + (input.servesPullRequests(project.environmentId) || isSelected(project)), + ), + input.environmentLabels, + projects.find(isSelected), + ); +} + export function pullRequestListKey( item: Pick, ) { diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts index ec3319f010843..59d7e0c83ad12 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { findScopedProject } from "./pullRequestList.logic"; -import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; const cups = EnvironmentId.make("env-cups"); const nucbox = EnvironmentId.make("env-nucbox"); diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index c4e917fa7134e..fe22fe5af2838 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -36,7 +36,7 @@ import { import type { PullRequestExpectedHost } from "./PullRequestListFilters"; import type { PullRequestListPreferences, PullRequestListSort } from "./pullRequestListPreferences"; import { assignProjectsToEnvironments } from "./pullRequestProjectAssignment.logic"; -import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; import { pullRequestListEntryId } from "./pullRequestListGroups.logic"; export interface PullRequestsSearch extends PullRequestListPreferences { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 5808641a5606a..e1f81638ea3d4 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -415,6 +415,10 @@ "types": "./src/threadReference.ts", "default": "./src/threadReference.ts" }, + "./pull-request-project-filter": { + "types": "./src/pullRequestProjectFilter.ts", + "default": "./src/pullRequestProjectFilter.ts" + }, "./thread-pull-request-compatibility": { "types": "./src/threadPullRequestCompatibility.ts", "default": "./src/threadPullRequestCompatibility.ts" diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/packages/client-runtime/src/pullRequestProjectFilter.ts similarity index 82% rename from apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts rename to packages/client-runtime/src/pullRequestProjectFilter.ts index 040f3537446cf..03e47dcafab16 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts +++ b/packages/client-runtime/src/pullRequestProjectFilter.ts @@ -1,8 +1,9 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; - -interface FilterProject extends AssignableProject { +interface FilterProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly repositoryIdentity?: { readonly canonicalKey?: string | undefined } | null | undefined; readonly title: string; readonly workspaceRoot: string; } @@ -25,7 +26,7 @@ function distinguishTitles( export function pullRequestFilterProjects( projects: ReadonlyArray, environmentLabels: ReadonlyMap, - selectedProject?: Pick, + selectedProject?: Pick, ) { const byRepository = new Map(); for (const project of projects) { @@ -45,7 +46,7 @@ export function pullRequestFilterProjects( ); const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); - return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => + return distinguishTitles(byEnvironment, (project) => project.id).sort((left, right) => left.title.localeCompare(right.title), ); } From 4e274aed25fdb94d71320c4565a1c3c35ca17093 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu Date: Thu, 29 Oct 2026 11:02:12 -0700 Subject: [PATCH 70/86] fix(shared): Open in Zed prefers zeditor over the ZFS zed on Linux (#23416) Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: maria-rcks --- .../src/process/externalLauncher.test.ts | 103 ++++++++++++++++++ packages/shared/src/editor.ts | 14 ++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1e816f31b77d5..265c4a2fc36bd 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -10,11 +10,14 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { EDITORS } from "@t3tools/contracts"; +import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; @@ -335,6 +338,106 @@ for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12 ); } +for (const { layout, pathNames, fallbackNames, expected } of [ + { layout: "PATH", pathNames: ["zed", "zeditor"], fallbackNames: [], expected: "zeditor" }, + { + layout: "mixed locations", + pathNames: ["zed"], + fallbackNames: ["zeditor"], + expected: "fallback", + }, + { + layout: "fallback directory", + pathNames: [], + fallbackNames: ["zed", "zeditor"], + expected: "fallback", + }, +]) { + it.effect.skipIf(windowsHost)(`prefers the Zed editor on Linux with ${layout}`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-linux-" }); + const binDir = path.join(home, "bin"); + const fallbackDir = path.join(home, ".local/bin"); + for (const [dir, names] of [ + [binDir, pathNames], + [fallbackDir, fallbackNames], + ] as const) { + yield* fs.makeDirectory(dir, { recursive: true }); + for (const name of names) { + yield* fs.writeFileString(path.join(dir, name), "#!/bin/sh\n"); + yield* fs.chmod(path.join(dir, name), 0o755); + } + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + assert.include(yield* launcher.resolveAvailableEditors(), "zed"); + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { HOME: home, PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.equal( + spawned.command, + expected === "fallback" ? path.join(fallbackDir, "zeditor") : expected, + ); + assert.deepEqual(spawned.args, ["/workspace"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} + +it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-fallback-" }); + const localBin = path.join(home, ".local/bin"); + const systemBin = path.join(home, "system-bin"); + for (const [dir, name] of [ + [localBin, "zed"], + [systemBin, "zeditor"], + ] as const) { + yield* fs.makeDirectory(dir, { recursive: true }); + const executable = path.join(dir, name); + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + } + const editor = EDITORS.find((editor) => editor.id === "zed")!; + const resolve = resolveEditorCommand(editor, { HOME: home, PATH: "" }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(Path.Path, { + ...path, + join: (...parts) => + path.join( + ...parts.map((part) => + ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, + ), + ), + }), + ); + assert.deepEqual( + yield* resolve, + Option.some({ command: path.join(systemBin, "zeditor"), baseArgs: [] }), + ); + yield* fs.remove(path.join(systemBin, "zeditor")); + assert.deepEqual( + yield* resolve, + Option.some({ command: path.join(localBin, "zed"), baseArgs: [] }), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.each([ { editor: "webstorm", installPath: "Applications/WebStorm 2026.2.app/Contents/MacOS/webstorm" }, { editor: "idea", installPath: "Applications/IntelliJ IDEA 2026.1.4.app/Contents/MacOS/idea" }, diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index dd9f11b0751e4..57bc67afdcfa6 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -34,7 +34,8 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun const platform = yield* HostProcess.Platform; // Prefer Zed's macOS bundle because OpenZFS also installs a zed executable. const preferBundle = platform === "darwin" && editor.id === "zed"; - if (!preferBundle) { + const preferZeditor = platform === "linux" && editor.id === "zed"; + if (!preferBundle && !preferZeditor) { for (const command of editor.commands) { if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs }); } @@ -129,8 +130,15 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local/share") : undefined); if (dataHome) dirs.push(path.join(dataHome, "JetBrains/Toolbox/scripts")); } - for (const dir of dirs) { - for (const name of editor.commands) candidates.push(path.join(dir, name)); + if (preferZeditor) { + // Linux packages use zeditor because OpenZFS owns the zed command. + for (const name of ["zeditor", "zed"]) { + candidates.push(name, ...dirs.map((dir) => path.join(dir, name))); + } + } else { + for (const dir of dirs) { + for (const name of editor.commands) candidates.push(path.join(dir, name)); + } } } From ef515949d0c1914a29a279ada1a45002f94601ed Mon Sep 17 00:00:00 2001 From: Koushik_xd <122906171+koushikxd@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:32:34 +0530 Subject: [PATCH 71/86] fix(server): native Codex subagents no longer show the parent's reasoning effort (#24467) Co-authored-by: maria-rcks --- .../Adapters/CodexAdapterV2.test.ts | 106 +++++++++++++++++- .../Adapters/CodexAdapterV2.ts | 12 +- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 69fca8c0825b8..19eb2a1e23913 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -16,6 +16,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, type OrchestrationV2TurnItem, ProjectId, type ProviderApprovalDecision, @@ -566,6 +567,7 @@ function codexReplayPreamble(input: { readonly nativeTurnId: string; readonly prompt: string; readonly sentPrompt?: string; + readonly effort?: string; }): Array { return [ { @@ -664,7 +666,14 @@ function codexReplayPreamble(input: { approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess" }, summary: "detailed", - collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + ...(input.effort === undefined ? {} : { effort: input.effort }), + collaborationMode: { + mode: "default", + settings: { + model: "gpt-5.4", + ...(input.effort === undefined ? {} : { reasoning_effort: input.effort }), + }, + }, }, }, }, @@ -6211,6 +6220,101 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each(["activity", "spawn", "activity then spawn"] as const)( + "creates a child from %s without the parent's effort", + (source) => + Effect.scoped( + Effect.gen(function* () { + const created = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const activity = { + type: "subAgentActivity", + id: "call-codex-resume-spawn", + kind: "started", + agentThreadId: RESUME_CHILD_THREAD, + agentPath: "/root/resume_agent", + }; + const spawn = { + type: "collabAgentToolCall", + id: "call-codex-resume-spawn", + tool: "spawnAgent", + status: "completed", + senderThreadId: RESUME_NATIVE_THREAD, + receiverThreadIds: [RESUME_CHILD_THREAD], + model: "gpt-5.4", + reasoningEffort: "high", + agentsStates: { [RESUME_CHILD_THREAD]: { status: "completed", message: "finished" } }, + }; + const items = + source === "activity" ? [activity] : source === "spawn" ? [spawn] : [activity, spawn]; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: RESUME_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: RESUME_NATIVE_THREAD, + nativeTurnId: RESUME_NATIVE_TURN, + prompt: RESUME_PROMPT, + effort: "low", + }), + ...items.map((item) => ({ + type: "emit_inbound" as const, + frame: { + method: "item/completed", + params: { + item, + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + completedAtMs: 1782622441000, + }, + }, + })), + ], + }), + (event) => + event.type === "app_thread.created" + ? Deferred.succeed(created, event.appThread) + : event.type === "subagent.updated" && event.subagent.status === "completed" + ? Deferred.succeed(completed, event.subagent) + : Effect.void, + ); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-child-effort"), + text: RESUME_PROMPT, + }), + modelSelection: { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "reasoningEffort", value: "low" }], + }, + }); + const child = yield* Deferred.await(created); + assert.equal( + child.modelSelection.options?.find(({ id }) => id === "reasoningEffort")?.value, + source === "spawn" ? "high" : undefined, + ); + if (source !== "activity") { + const task = yield* Deferred.await(completed); + assert.equal( + task.modelSelection?.options?.find(({ id }) => id === "reasoningEffort")?.value, + "high", + ); + assert.lengthOf( + harness.events.filter((event) => event.type === "app_thread.created"), + 1, + ); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("preserves a subagent result across a trailing empty final and resume", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 58cd4a0c0c17b..51796da166eda 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2717,9 +2717,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly emitInitialPrompt: boolean; }) { const registeredSubagents = yield* Ref.get(subagentThreads); - if (registeredSubagents.has(input.nativeThreadId)) { - return; - } if (input.model && !subagentSelections.has(input.nativeThreadId)) { yield* updateSubagentSelection( input.nativeThreadId, @@ -2727,6 +2724,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( input.reasoningEffort, ); } + if (registeredSubagents.has(input.nativeThreadId)) { + return; + } const now = yield* DateTime.now; const subagentNodeId = idAllocator.derive.nodeFromProviderItem({ @@ -2803,9 +2803,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( parentNodeId: subagentNodeId, activeProviderThreadId: providerThread.id, providerInstanceId: input.context.input.modelSelection.instanceId, - modelSelection: { + // A child's effort can differ from the parent's. Until Codex reports it, show none. + modelSelection: task.modelSelection ?? { ...input.context.input.modelSelection, model: task.model ?? input.context.input.modelSelection.model, + options: (input.context.input.modelSelection.options ?? []).filter( + ({ id }) => id !== "reasoningEffort", + ), }, title: subagentThreadTitle({ parentTitle: input.context.projectionAppThread.title, From 3bd64cf2c1c72c3c09d8258653a8e1e2d30b5116 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:02:57 -0700 Subject: [PATCH 72/86] fix(server): mark unread works after an automatic compaction (#25286) Co-authored-by: maria-rcks --- .../src/orchestration-v2/Orchestrator.ts | 10 +- .../src/orchestration-v2/runtimeLayer.test.ts | 108 ++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index d0edd5dacf8f6..94de805b46522 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3229,8 +3229,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } let markUnreadVisitedAt: DateTime.Utc | null = null; if (command.type === "thread.mark-unread") { - const projection = yield* loadProjectionForCommand(command, ["runs"]); - const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null; + const shell = yield* projectionStore + .getThreadShell(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const latestRunCompletedAt = shell?.latestRunCompletedAt ?? null; if (latestRunCompletedAt === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 65c0c040e0daf..428202d17b651 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6278,6 +6278,114 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { assert.instanceOf(invalidVisit, Orchestrator.OrchestratorDispatchError); }), ); + + it.effect("marks unread before the user's last turn when an automatic compaction followed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-mark-unread-compacted"); + const turnCompletedAt = DateTime.makeUnsafe("2026-07-24T10:00:00.000Z"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Compacted thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const runs = [ + { text: "Continue", at: turnCompletedAt }, + { + text: "/compact", + idleCompaction: "beforeCacheExpires" as const, + at: DateTime.makeUnsafe("2026-07-24T11:00:00.000Z"), + }, + ]; + yield* (yield* EventSink.EventSinkV2).write({ + commandId: CommandId.make(`${threadId}:seed`), + events: runs.flatMap(({ at, ...message }, index) => { + const runId = RunId.make(`${threadId}:run-${index}`); + const messageId = MessageId.make(`${threadId}:message-${index}`); + return [ + { + id: EventId.make(`${messageId}:event`), + type: "message.updated" as const, + threadId, + occurredAt: at, + payload: { + ...message, + id: messageId, + threadId, + runId, + nodeId: null, + role: "user" as const, + attachments: [], + streaming: false, + createdBy: "user" as const, + creationSource: "web" as const, + createdAt: at, + updatedAt: at, + }, + }, + { + id: EventId.make(`${runId}:event`), + type: "run.created" as const, + threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: index + 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "completed" as const, + queuePosition: null, + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }, + ]; + }), + }); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make(`${threadId}:mark-unread`), + threadId, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + projection.thread.lastVisitedAt, + DateTime.subtract(turnCompletedAt, { milliseconds: 1 }), + ); + const shell = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(shell!.latestRunCompletedAt, turnCompletedAt); + assert.deepEqual(shell!.lastVisitedAt, projection.thread.lastVisitedAt); + + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make(`${threadId}:visit`), + threadId, + visitedAt: DateTime.formatIso(turnCompletedAt), + }); + const revisited = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(revisited!.lastVisitedAt, turnCompletedAt); + }), + ); }); it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("thread opens", (it) => { From a1621a81af727fe40bdb2a351860b2704827d31c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:03:19 +0000 Subject: [PATCH 73/86] fix(server): show MCP App forms from failed tool results (#25225) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/mobile/src/lib/threadActivity.test.ts | 18 +++++ apps/mobile/src/lib/threadActivity.ts | 5 +- .../Adapters/CodexAdapterV2.test.ts | 67 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 10 ++- apps/web/src/session-logic.test.ts | 17 +++++ apps/web/src/session-logic.ts | 5 +- 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index f2ced2d689794..96626a5caefab 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -38,6 +38,24 @@ const threadId = ThreadId.make("thread-1"); const sourceThreadId = ThreadId.make("thread-source"); const runId = RunId.make("run-1"); +it("shows a captured MCP app for a failed deferred write", () => { + const mcpApp = { + attachmentId: "deferred-app-html", + server: "github", + tool: "create_pull_request", + resourceUri: "ui://github-mcp-server/pr-create", + }; + const item: OrchestrationV2TurnItem = { + ...base("deferred-app", "2026-08-29T00:00:00.000Z", 1), + type: "dynamic_tool", + toolName: "github.create_pull_request", + status: "failed", + input: {}, + output: { t3McpApp: mcpApp, result: { content: [], isError: true } }, + }; + expect(buildThreadFeed([projected(item, 0)])).toMatchObject([{ type: "mcp-app", app: mcpApp }]); +}); + it("keeps historical plan detail accessible from its paged turn item", () => { const item = { ...base("historical-plan", "2026-08-29T00:00:00.000Z", 1), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 4cc5a842c21f9..bd7af44b2fa07 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2102,10 +2102,7 @@ export function buildThreadFeed( entryRows.push(index); continue; } - const app = - item.type === "dynamic_tool" && item.status === "completed" - ? mcpAppFromToolItem(item) - : undefined; + const app = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; if (app) { const entry: RawThreadFeedEntry = { type: "mcp-app", diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 19eb2a1e23913..c20994423f3e9 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5138,6 +5138,73 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + const MCP_APP_DEFERRED_RESULT = { + content: [{ type: "text", text: "An interactive form is being shown to the user." }], + structuredContent: { status: "awaiting_user_submission" }, + }; + const mcpAppDeferredTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: mcpAppTranscript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === "item/completed/app-tool" + ? { + ...entry, + frame: { + method: "item/completed", + params: { + item: { + ...mcpAppToolItem("inProgress"), + status: "failed", + result: MCP_APP_DEFERRED_RESULT, + }, + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + completedAtMs: 1782622441500, + }, + }, + } + : entry, + ), + }); + + it.effect("captures the app of a tool call that defers to its form", () => + Effect.scoped( + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppDeferredTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "failed" + ? Deferred.succeed(appCaptured, event.turnItem) + : Effect.void, + ); + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-mcp-app-deferred"), + text: MCP_APP_PROMPT, + }), + ); + const item = yield* Deferred.await(appCaptured); + + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + assert.equal( + readMcpAppReference(output[MCP_APP_OUTPUT_KEY])?.resourceUri, + MCP_APP_RESOURCE, + ); + assert.deepEqual(output.result, { ...MCP_APP_DEFERRED_RESULT, isError: true }); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool"; const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread"; const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 51796da166eda..51a686e270389 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -3744,7 +3744,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const reference = - item.status === "completed" && item.result != null && item.error == null + item.result != null && item.error == null ? yield* restore( getNativeThreadId(context.providerThread).pipe( Effect.flatMap((threadId) => @@ -3798,7 +3798,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ? artifacts.turnItem : { ...artifacts.turnItem, - output: { [MCP_APP_OUTPUT_KEY]: reference, result: item.result }, + output: { + [MCP_APP_OUTPUT_KEY]: reference, + result: + item.status === "failed" + ? { ...item.result, isError: true } + : item.result, + }, }; yield* emitProviderEvent({ type: "node.updated", diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 9c4e38c30e510..df3eb85c3db38 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1307,6 +1307,23 @@ describe("HTML renders in the timeline", () => { expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "mcp-app", "message"]); }); + it("hosts an MCP app whose call failed, as a form-deferring write does", () => { + const mcpApp = { + attachmentId: "render-thread-app-html", + server: "github", + tool: "create_pull_request", + resourceUri: "ui://github-mcp-server/pr-create", + }; + const call = renderCall("failed", { + t3McpApp: mcpApp, + result: { content: [], isError: true }, + }); + const entries = turn( + call.type === "dynamic_tool" ? { ...call, toolName: "github.create_pull_request" } : call, + ); + expect(entries.find((entry) => entry.kind === "mcp-app")).toMatchObject({ mcpApp }); + }); + it("keeps a render visible when its superseded attempt folds", () => { const attempt: OrchestrationV2RunAttempt = { id: RunAttemptId.make("attempt-superseded"), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7b79dea6f7401..6cf2761d3d6d3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -711,10 +711,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( continue; } - const mcpApp = - item.type === "dynamic_tool" && item.status === "completed" - ? mcpAppFromToolItem(item) - : undefined; + const mcpApp = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; if (mcpApp !== undefined) { entries.push({ id: item.id, From ad5ba437bd0b467879915efa9a14411b71a54fe7 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:03:42 +0000 Subject: [PATCH 74/86] fix(server): restore Linux session display for editor launches (#25228) Co-authored-by: Inaya Yousfi <193243337+inayayousfi@users.noreply.github.com> --- .../src/process/externalLauncher.test.ts | 73 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 54 +++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 265c4a2fc36bd..3262c555bcf8c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -140,6 +140,79 @@ it.effect.skipIf(windowsHost)( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +// Windows drive letters cannot resolve the POSIX editor stub. +it.effect.skipIf(windowsHost)( + "a Linux editor launch without a display takes the desktop session's display from the systemd user manager", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const codiumPath = path.join(binDir, "codium"); + yield* fileSystem.writeFileString(codiumPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(codiumPath, 0o755); + + const launchEnv = (sessionManager: MockSpawnResult) => + Effect.gen(function* () { + const spawned: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "vscodium", cwd: "/workspace" }); + yield* launcher.launchBrowser("https://example.com"); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { PATH: binDir }, + onSpawn: (command) => spawned.push(command), + spawnResult: (command) => + command.command === "busctl" ? sessionManager : undefined, + }), + ), + Effect.provideService(HostProcess.Environment, { PATH: binDir }), + ); + // Only editors borrow the session display; a browser must not open on that screen. + assert.deepEqual(spawned.find((command) => command.command === "xdg-open")?.options.env, { + PATH: binDir, + }); + return spawned.find((command) => command.command === "codium")?.options.env; + }); + + assert.deepEqual( + yield* launchEnv({ + stdout: JSON.stringify({ + type: "as", + data: [ + "DISPLAY=:0", + "HOME=/home/user", + "WAYLAND_DISPLAY=wayland-1", + "XAUTHORITY=/home/user/auth files/cookie", + ], + }), + }), + { + PATH: binDir, + DISPLAY: ":0", + WAYLAND_DISPLAY: "wayland-1", + // Raw, not the $'...' quoting `systemctl show-environment` would print. + XAUTHORITY: "/home/user/auth files/cookie", + }, + ); + assert.deepEqual(yield* launchEnv({ stdout: "invalid JSON" }), { PATH: binDir }); + assert.deepEqual(yield* launchEnv({ stdout: '{"data":[42]}' }), { PATH: binDir }); + assert.deepEqual( + yield* launchEnv({ + spawnError: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + { PATH: binDir }, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index b8c0911a520d2..6a2eb145162e2 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -31,6 +31,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -672,6 +673,56 @@ const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* ( yield* launchEditorProcess(launch); }); +const GRAPHICAL_SESSION_ENV_KEYS: ReadonlySet = new Set([ + "DISPLAY", + "WAYLAND_DISPLAY", + "XAUTHORITY", +]); + +// The user manager's environment over D-Bus. `systemctl show-environment` would +// shell-quote values with spaces as $'...', while this JSON carries them raw. +const decodeManagerEnvironment = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ data: Schema.Array(Schema.String) })), +); + +// Boot services miss the desktop environment imported later into the systemd user manager. +const readGraphicalSessionEnv = Effect.fn("externalLauncher.readGraphicalSessionEnv")( + function* () { + const hostEnv = yield* HostProcess.Environment; + if ((yield* HostProcess.Platform) !== "linux" || hasGraphicalLinuxSession(hostEnv)) { + return {}; + } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner.string( + ChildProcess.make( + "busctl", + [ + "--user", + "--json=short", + "get-property", + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "Environment", + ], + { stdin: "ignore", stderr: "ignore" }, + ), + ); + const { data } = yield* decodeManagerEnvironment(output); + return Object.fromEntries( + data.flatMap((line) => { + const separator = line.indexOf("="); + const key = line.slice(0, separator); + return separator > 0 && GRAPHICAL_SESSION_ENV_KEYS.has(key) + ? [[key, line.slice(separator + 1)]] + : []; + }), + ); + }, + Effect.timeout("2 seconds"), + Effect.orElseSucceed((): NodeJS.ProcessEnv => ({})), +); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -681,7 +732,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment; const command = ChildProcess.make(launch.command, launch.args, { ...launch.options, - env, + env: { ...env, ...launch.options.env }, extendEnv: false, }); @@ -735,6 +786,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu options: { detached: launch.detached ?? true, shell: spawnCommand.shell, + env: yield* readGraphicalSessionEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", From 9e60e945d27a27f6505d0718122473454ce4afae Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:04:04 +0000 Subject: [PATCH 75/86] fix(usage): keep host identity across container recreation (#25220) Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/usage/UsageService.test.ts | 89 ++++++++++++++++++++++ apps/server/src/usage/UsageService.ts | 35 ++++++++- docs/user/usage.md | 4 + 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index b77d3437bef78..bee7a8a480bc1 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -142,6 +142,7 @@ const layerService = (input: { ) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; + readonly hostname?: string; }) => layerCursorUsageAccounts( CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), @@ -157,6 +158,7 @@ const layerService = (input: { ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), + Layer.provideMerge(Layer.succeed(HostProcess.Hostname, input.hostname ?? "usage-test-host")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -465,6 +467,93 @@ describe("Codex usage history", () => { }); describe("UsageService", () => { + it.live("keeps the first host identity across recreation and explicit override removal", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + for (const [hostname, override, expected] of [ + ["container-a", undefined, "container-a"], + ["container-b", undefined, "container-a"], + ["container-c", "physical-host", "physical-host"], + ["container-d", undefined, "container-a"], + ] as const) { + const summary = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-host-id", + home, + settings, + hostname, + environment: override === undefined ? {} : { T3CODE_HOST_ID: override }, + }), + ), + ); + const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); + assert.strictEqual(source?.fingerprint.hostId, expected); + } + }).pipe(Effect.scoped), + ); + + it.live("concurrent initialization keeps one host identity across service recreation", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const prefix = "usage-host-id-race"; + const hostIdPath = NodePath.join(home, prefix, "usage-host-id"); + const bothRead = yield* Deferred.make(); + let reads = 0; + const summaries = yield* Effect.forEach( + ["container-a", "container-b"], + (hostname) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + let firstRead = true; + const service = yield* UsageService.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path, encoding) => + Effect.gen(function* () { + if (path !== hostIdPath || !firstRead) { + return yield* fileSystem.readFileString(path, encoding); + } + firstRead = false; + // Both instances observe the missing file before either can create it. + const result = yield* Effect.exit(fileSystem.readFileString(path, encoding)); + reads += 1; + if (reads === 2) yield* Deferred.succeed(bothRead, undefined); + yield* Deferred.await(bothRead); + return yield* result; + }), + }), + ); + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix, home, settings, hostname })), + ), + { concurrency: "unbounded" }, + ); + const persisted = yield* Effect.promise(() => NodeFSP.readFile(hostIdPath, "utf8")); + const hostId = persisted.trim(); + assert.include(["container-a", "container-b"], hostId); + const restarted = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix, home, settings, hostname: "container-c" })), + ); + for (const summary of [...summaries, restarted]) { + const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); + assert.strictEqual(source?.fingerprint.hostId, hostId); + } + }).pipe(Effect.scoped), + ); + it.live( "repairs initial Codex prices after unchanged-file retries, persists evidence and corrections, and honors later native tiers", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 6150ab1d3cc35..c1f619eaae177 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import { type ProviderInstanceConfig, ProviderInstanceId, @@ -155,6 +153,7 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcess.Environment; + const hostname = yield* HostProcess.Hostname; const readerContext = yield* Effect.context(); const fileCache: ScanCache = new Map(); @@ -177,6 +176,37 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); + const hostIdPath = path.join(config.baseDir, "usage-host-id"); + const explicitHostId = hostEnvironment["T3CODE_HOST_ID"]?.trim(); + const hostId = + explicitHostId || + (yield* fileSystem.readFileString(hostIdPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => + Effect.scoped( + Effect.gen(function* () { + yield* fileSystem.makeDirectory(config.baseDir, { recursive: true }); + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + directory: config.baseDir, + prefix: "usage-host-id.", + }); + const tempFile = path.join(tempDirectory, "identity"); + yield* fileSystem.writeFileString(tempFile, `${hostname}\n`); + // Publish complete contents without replacing another initializer's identity. + yield* fileSystem.link(tempFile, hostIdPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); + return yield* fileSystem.readFileString(hostIdPath); + }), + ), + ), + Effect.map((value) => value.trim() || hostname), + Effect.orElseSucceed(() => hostname), + )); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesLastFailedAtMs: number | null = null; @@ -664,7 +694,6 @@ export const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureScanCacheLoaded; - const hostId = NodeOS.hostname(); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ diff --git a/docs/user/usage.md b/docs/user/usage.md index 155175351c550..af1fc02352f83 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -43,6 +43,10 @@ Use absolute paths or `~/` paths in the account's environment settings; relative depend on each project's working directory and cannot be reliably discovered by Usage. Accounts sharing a history directory count once. +The server saves its first hostname in `/usage-host-id`, so a recreated container keeps +its usage identity when T3 home is persisted. Set `T3CODE_HOST_ID` to choose the identity +explicitly. + When your app and server support different providers, usage totals may cover only the providers your app understands. Update the app to include newly supported providers. From d796063f78a96c8816196fac3fa5660bebdd99a9 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:04:25 +0000 Subject: [PATCH 76/86] fix(opencode): authenticate with the resolved server password (#25230) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/server/adapter.test.ts | 126 ++++++++++++++++++ .../provider-opencode/src/server/adapter.ts | 7 +- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 41da6bd0ff435..52f7590e63f42 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - Exercise HTTP authentication with the real SDK. +import * as NodeHttp from "node:http"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; @@ -50,6 +55,7 @@ import { import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeOpenCodeSettings = Schema.decodeEffect(OpenCodeSettings); const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ serverUrl: "http://test.invalid", }); @@ -2671,3 +2677,123 @@ it.effect.each([false, true])( Effect.scoped, ), ); + +it.live.each([true, false])("opens an authenticated OpenCode session (external=%s)", (external) => + Effect.gen(function* () { + const password = external ? "settings-password" : "environment-password"; + const requests: string[] = []; + let eventConnected!: () => void; + const eventRequest = new Promise((resolve) => { + eventConnected = resolve; + }); + const server = yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise>((resolve) => { + const server = NodeHttp.createServer((request, response) => { + if ( + request.headers.authorization !== + `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` + ) { + response.writeHead(401).end("Unauthorized"); + return; + } + const url = new URL(request.url!, "http://localhost"); + requests.push(url.pathname); + if (url.pathname === "/event") { + response.writeHead(200, { "Content-Type": "text/event-stream" }); + response.write(": connected\n\n"); + eventConnected(); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify( + url.pathname.endsWith("/children") + ? [] + : url.pathname === "/session" + ? { id: "native-auth", time: { created: 1, updated: 1 } } + : { healthy: true, version: "1.14.19" }, + ), + ); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); + const address = server.address(); + assert.isNotNull(address); + if (!address || typeof address === "string") return; + const url = `http://127.0.0.1:${address.port}`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + let managedStarts = 0; + const runtimeLayer = OpenCodeRuntime.layer.pipe( + Layer.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + assert.isTrue(ChildProcess.isStandardCommand(command)); + if (!ChildProcess.isStandardCommand(command)) return Effect.die("Unexpected pipeline"); + assert.include(command.args, "serve"); + assert.equal(command.options.env?.OPENCODE_SERVER_PASSWORD, password); + managedStarts += 1; + return spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "-e", + `console.log("server listening on " + process.argv[1]); setInterval(() => {}, 1000)`, + url, + ], + command.options, + ), + ); + }), + ), + ), + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provideMerge(NodeServices.layer), + ); + const instanceId = ProviderInstanceId.make("opencode-auth"); + const adapter = yield* makeOpenCodeAdapterV2({ + instanceId, + settings: yield* decodeOpenCodeSettings({ + serverUrl: external ? url : "", + ...(external ? { serverPassword: password } : {}), + }), + environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, + }).pipe(Effect.provide(runtimeLayer)); + const session = yield* adapter.openSession({ + threadId: ThreadId.make("thread-opencode-auth"), + providerSessionId: ProviderSessionId.make("session-opencode-auth"), + modelSelection: { instanceId, model: "anthropic/claude-sonnet", options: [] }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }); + yield* session.ensureThread({ + threadId: ThreadId.make("thread-opencode-auth"), + modelSelection: { instanceId, model: "anthropic/claude-sonnet", options: [] }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }); + assert.include(requests, "/session"); + yield* Effect.promise(() => eventRequest); + assert.include(requests, "/event"); + assert.include(requests, "/global/health"); + assert.equal(managedStarts, external ? 0 : 1); + }).pipe( + Effect.scoped, + Effect.provide([ + NodeServices.layer, + TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), + IdAllocator.layer, + McpProviderSessions.layer, + ]), + ), +); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 2a851188d076e..7762c3b64503d 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -936,13 +936,16 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function binaryPath: options.settings.binaryPath, directory: cwd, serverUrl: options.settings.serverUrl, + ...(options.settings.serverPassword + ? { serverPassword: options.settings.serverPassword } + : {}), environment: options.environment, }); const client = runtime.createOpenCodeSdkClient({ baseUrl: connection.url, directory: cwd, - ...(connection.external && options.settings.serverPassword - ? { serverPassword: options.settings.serverPassword } + ...(connection.serverPassword !== undefined + ? { serverPassword: connection.serverPassword } : {}), }); From f06dd4bbf5ad19226344279effcaa5928ce452f0 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:04:47 +0000 Subject: [PATCH 77/86] feat(web): allow resizing the file explorer (#25737) Co-authored-by: Rishabh Bothra <37180068+07rjain@users.noreply.github.com> --- .../src/components/files/FileExplorerPane.tsx | 90 +++++++++++++++++++ .../src/components/files/FilePreviewPanel.tsx | 12 +-- .../preview/RightPanelResizeHandle.tsx | 13 ++- apps/web/src/hooks/useResizableWidth.ts | 19 ++-- 4 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/files/FileExplorerPane.tsx diff --git a/apps/web/src/components/files/FileExplorerPane.tsx b/apps/web/src/components/files/FileExplorerPane.tsx new file mode 100644 index 0000000000000..c13a814f63051 --- /dev/null +++ b/apps/web/src/components/files/FileExplorerPane.tsx @@ -0,0 +1,90 @@ +import { type CSSProperties, type ReactNode, useState } from "react"; + +import { RightPanelResizeHandle } from "~/components/preview/RightPanelResizeHandle"; +import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "~/hooks/useResizableWidth"; +import { observeResize } from "~/lib/observeResize"; +import { cn } from "~/lib/utils"; + +const MIN_WIDTH = 160; +const MAX_WIDTH = 560; +const FILE_CONTENT_MIN_WIDTH = 256; + +export function FileExplorerPane({ + resizable, + children, +}: { + resizable: boolean; + children: ReactNode; +}) { + const [maxWidth, setMaxWidth] = useState(MAX_WIDTH); + const { width, setWidth, handlers } = useResizableWidth({ + storageKey: "t3code.fileExplorerWidth", + defaultWidth: 256, + minWidth: MIN_WIDTH, + maxWidth, + edge: "left", + }); + + return ( + + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 03e682758e321..e9b257e3e312c 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -84,6 +84,7 @@ import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; import { PreviewImage } from "./PreviewImage"; import FileBrowserPanel from "./FileBrowserPanel"; +import { FileExplorerPane } from "./FileExplorerPane"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { breadcrumbPathOf, retainedBreadcrumbTrail, trailChildOf } from "./filePath"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; @@ -1513,14 +1514,7 @@ export default function FilePreviewPanel({ ) : null}
{showExplorer ? ( - + ) : null}
diff --git a/apps/web/src/components/preview/RightPanelResizeHandle.tsx b/apps/web/src/components/preview/RightPanelResizeHandle.tsx index dd644ccf0c822..851daaebe5eab 100644 --- a/apps/web/src/components/preview/RightPanelResizeHandle.tsx +++ b/apps/web/src/components/preview/RightPanelResizeHandle.tsx @@ -1,16 +1,23 @@ +import type { ComponentProps } from "react"; import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; -interface Props { +interface Props extends Pick< + ComponentProps<"div">, + "tabIndex" | "onKeyDown" | "aria-valuenow" | "aria-valuemin" | "aria-valuemax" +> { handlers: ResizableWidthHandlers; className?: string; + label?: string; } -export function RightPanelResizeHandle({ handlers, className }: Props) { +export function RightPanelResizeHandle({ handlers, className, label, ...separatorProps }: Props) { return (
); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 16bb19829135b..50fdba38a9fc5 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -29,6 +29,7 @@ export interface ResizableWidthHandlers { /** Persist on drag end to avoid a storage write on every animation frame. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; + readonly setWidth: (width: number) => void; readonly handlers: ResizableWidthHandlers; } { const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; @@ -55,6 +56,16 @@ export function useResizableWidth(options: UseResizableWidthOptions): { const clampedWidth = clamp(widthState.width); + const commitWidth = (nextStorageKey: string, nextWidth: number) => { + // Resizing remains usable for this session when browser storage is unavailable. + setWidthState({ storageKey: nextStorageKey, width: nextWidth }); + try { + setLocalStorageItem(nextStorageKey, nextWidth, WidthSchema); + } catch (error) { + console.error("Could not persist panel width.", error); + } + }; + const drag = useResizeDrag((event) => { const host = event.currentTarget.parentElement; // Disable drag transitions through transition-property; the host renders transition-duration. @@ -72,12 +83,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return nextWidth; }, finish(finalWidth) { - setWidthState({ storageKey: startStorageKey, width: finalWidth }); - try { - setLocalStorageItem(startStorageKey, finalWidth, WidthSchema); - } catch (error) { - console.error("Could not persist panel width.", error); - } + commitWidth(startStorageKey, finalWidth); }, cleanup(committed) { host?.style.removeProperty("transition-property"); @@ -94,6 +100,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return { width: clampedWidth, + setWidth: (value) => commitWidth(storageKey, clamp(value)), handlers: { ref: drag.ref, onPointerDown: drag.onPointerDown, From f4147ddf7948c30163714975dc8b9de671434f3c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:05:09 +0000 Subject: [PATCH 78/86] fix(server): update shared Codex installs from their owning home (#25235) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/provider/Drivers/CodexDriver.test.ts | 101 ++++++++++++++++++ .../src/provider/Drivers/CodexDriver.ts | 27 +++-- .../src/server/maintenanceResolver.ts | 9 +- 3 files changed, 129 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 90e37cc66712b..0aacca303e6a6 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -377,6 +377,107 @@ it.layer(layerTest)("CodexDriver", (it) => { ), ); + it.effect.skipIf(windowsHost)( + "runs the standalone updater against the home that holds a shared install", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + // One standalone install, linked onto PATH and used by two account homes. + // `İ` gets longer when lowercased, so the home must not be sliced from a + // lowercased copy of the path. + const installHome = NodePath.join(tempDir, "codex-İnstall"); + const realBinaryPath = NodePath.join( + installHome, + "packages", + "standalone", + "releases", + "0.162.1-x86_64-unknown-linux-musl", + "bin", + "codex", + ); + const binaryPath = NodePath.join(tempDir, "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(realBinaryPath), { recursive: true }); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); + yield* fs.chmod(realBinaryPath, 0o755); + yield* fs.symlink(realBinaryPath, binaryPath); + + const updates = yield* Effect.forEach(["codex-p", "codex-vm"], (account) => + Effect.gen(function* () { + const homePath = NodePath.join(tempDir, `home-${account}`); + yield* fs.makeDirectory(homePath, { recursive: true }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(account), + displayName: account, + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath }, + }); + return (yield* instance.snapshot.resolveMaintenance()).update; + }), + ); + + for (const update of updates) { + expect(update).toMatchObject({ + command: `${binaryPath} update`, + args: ["update"], + lockKey: "codex-native", + env: { CODEX_HOME: installHome }, + }); + } + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + + it.effect.skipIf(windowsHost)("derives the standalone update home from either command path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-update-home-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const linkedInstallHome = NodePath.join(tempDir, "install"); + const fixtures = [ + { binary: "codex", realPath: "/packages/standalone/bin/codex", home: "/" }, + { binary: "codex", realPath: "C:\\Packages\\Standalone\\bin\\codex.exe", home: "C:\\" }, + // The standalone tree itself is a symlink, so only the resolved path names its home. + { + binary: NodePath.join("install", "packages", "standalone", "bin", "codex"), + realPath: "/disk/codex/releases/0.163.0/bin/codex", + home: linkedInstallHome, + }, + ]; + + for (const fixture of fixtures) { + const binaryPath = NodePath.join(tempDir, fixture.binary); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-update-home"), + displayName: "Codex update home", + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath: tempDir }, + }).pipe( + // Virtualize only the executable target: these trees do not belong to this test. + Effect.provideService(FileSystem.FileSystem, { + ...fs, + realPath: (path) => + path === binaryPath ? Effect.succeed(fixture.realPath) : fs.realPath(path), + }), + ); + expect((yield* instance.snapshot.resolveMaintenance()).update?.env).toEqual({ + CODEX_HOME: fixture.home, + }); + } + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), + ); + for (const fixture of [ { name: "leaves mise npm-backend installations manual-only", diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 2d078cf32594f..bbd910cf5d4a9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -37,7 +37,6 @@ import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "@t3tools/provider-core/server/maintenanceResolver"; import { @@ -60,20 +59,36 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); -function isCodexStandaloneCommandPath(commandPath: string): boolean { - return normalizeCommandPath(commandPath).includes("/packages/standalone/"); +const CODEX_STANDALONE_HOME_PATTERN = /^(.*)\/packages\/standalone\//is; + +function codexStandaloneHome(commandPath: string): string | null { + // Slash replacement preserves indices; Unicode lowercasing can change the path length. + const match = CODEX_STANDALONE_HOME_PATTERN.exec(commandPath.replaceAll("\\", "/")); + if (!match) { + return null; + } + const home = match[1]!; + // Keep root separators: `C:` is drive-relative, and an empty home is not `/`. + const isRoot = home === "" || /^[a-z]:$/i.test(home); + return commandPath.slice(0, home.length + (isRoot ? 1 : 0)); } -/** `codex update` owns the shared home's standalone tree; auth-overlay homes do not carry it. */ +// Standalone updates locate their install through CODEX_HOME. Account and auth-overlay +// homes can share an executable, so only the updater uses the executable's owning home. function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", nativeUpdate: { args: ["update"], - isCommandPath: isCodexStandaloneCommandPath, + isCommandPath: (commandPath) => codexStandaloneHome(commandPath) !== null, requiresCommandPath: true, - env: { CODEX_HOME: sharedHomePath }, + env: (context) => ({ + CODEX_HOME: + codexStandaloneHome(context.realCommandPath) ?? + codexStandaloneHome(context.resolvedCommandPath) ?? + sharedHomePath, + }), }, }); } diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 2e3aad166c58e..8a8e11e2ec0d7 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -87,7 +87,9 @@ export interface PackageManagedProviderMaintenanceDefinition { readonly isCommandPath?: (commandPath: string) => boolean; /** The updater cannot detect installs outside `isCommandPath`, so leave those manual-only. */ readonly requiresCommandPath?: boolean; - readonly env?: NodeJS.ProcessEnv; + readonly env?: + | NodeJS.ProcessEnv + | ((context: ProviderMaintenanceResolutionContext) => NodeJS.ProcessEnv); } | null; } @@ -446,6 +448,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( } const nativeUpdate = definition.nativeUpdate; + const nativeEnv = nativeUpdate?.env; const native = nativeUpdate ? makeProviderMaintenanceCapabilities({ provider: definition.provider, @@ -454,7 +457,9 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateArgs: nativeUpdate.args, updateLockKey: `${definition.provider}-native`, platform: context.platform, - ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), + ...(nativeEnv + ? { env: typeof nativeEnv === "function" ? nativeEnv(context) : nativeEnv } + : {}), }) : manual; // A native-looking launcher can still delegate to a mise-owned install. From 8bcc9911f11e5dd9666e1a1b625766e0ccd2cb49 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 13:05:45 -0500 Subject: [PATCH 79/86] fix(cli): keep published runtimes in place during installation (#24092) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- scripts/build-npm-launcher.test.ts | 112 +++++++++++++++++++++++++++++ scripts/npm-launcher/t3.mjs | 29 +++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/scripts/build-npm-launcher.test.ts b/scripts/build-npm-launcher.test.ts index 749e27bd5454a..4c7edd4763359 100644 --- a/scripts/build-npm-launcher.test.ts +++ b/scripts/build-npm-launcher.test.ts @@ -195,6 +195,118 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { VERSION, ]); + // A peer's executable and sidecar stay available after every later rename or removal, + // even when it publishes after our missing-marker read. + const staleHome = path.join(root, "stale-home"); + const staleDir = path.join(staleHome, "runtime/versions", VERSION); + const peerDir = path.join(root, "peer-runtime"); + yield* fs.makeDirectory(staleDir, { recursive: true }); + yield* fs.writeFileString(path.join(staleDir, "t3"), ""); + yield* fs.makeDirectory(peerDir); + yield* fs.writeFileString(path.join(peerDir, "t3"), '#!/bin/sh\necho "peer $*"\nexit 7\n'); + yield* fs.chmod(path.join(peerDir, "t3"), 0o755); + yield* fs.writeFileString(path.join(peerDir, ".install-complete"), `${VERSION}\n`); + yield* fs.writeFileString(path.join(peerDir, "sidecar"), "peer sidecar"); + const peerChecks = path.join(root, "peer-checks.txt"); + // The peer publishes after the launcher's rename fails and it reads the marker. + const peerHook = path.join(root, "peer-publishes.mjs"); + yield* fs.writeFileString( + peerHook, + `import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +const dir = ${JSON.stringify(staleDir)}; +const { readFileSync, renameSync, rmSync } = fs; +let checking = false; +let published = false; +const checkPeer = () => { + const child = spawnSync(dir + "/t3", ["--version"], { encoding: "utf8" }); + let sidecar; + try { + sidecar = readFileSync(dir + "/sidecar", "utf8"); + } catch (error) { + sidecar = error.code; + } + fs.appendFileSync(${JSON.stringify(peerChecks)}, + [child.status, child.error?.code ?? child.stdout.trim(), sidecar].join("|") + "\\n"); +}; +fs.renameSync = (from, to) => { + try { + return renameSync(from, to); + } catch (error) { + checking ||= to === dir; + throw error; + } finally { + if (published) checkPeer(); + } +}; +fs.rmSync = (...args) => { + try { + return rmSync(...args); + } finally { + if (published) checkPeer(); + } +}; +fs.readFileSync = (file, ...rest) => { + try { + return readFileSync(file, ...rest); + } finally { + if (checking && file === dir + "/.install-complete") { + checking = false; + rmSync(dir, { recursive: true }); + renameSync(${JSON.stringify(peerDir)}, dir); + published = true; + checkPeer(); + } + } +}; +`, + ); + const recovered = yield* run( + process.execPath, + ["--import", peerHook, launcher, "--version"], + { cwd: root, env: { ...env, T3CODE_HOME: staleHome } }, + ); + const checks = (yield* fs.readFileString(peerChecks)).trim().split("\n"); + assert.isAtLeast(checks.length, 2); + assert.deepStrictEqual([...new Set(checks)], ["7|peer --version|peer sidecar"]); + assert.equal(recovered.stdout.trim(), "peer --version", recovered.stderr); + assert.equal(recovered.exitCode, 7); + assert.deepStrictEqual(yield* fs.readDirectory(path.join(staleHome, "runtime/versions")), [ + VERSION, + ]); + + // While another launch holds the lock, a stale folder is left alone and the error says + // how to clear a lock left by a crash. + const staleOnlyHome = path.join(root, "stale-only-home"); + const staleOnlyDir = path.join(staleOnlyHome, "runtime/versions", VERSION); + yield* fs.makeDirectory(staleOnlyDir, { recursive: true }); + yield* fs.writeFileString(path.join(staleOnlyDir, "t3"), "incomplete"); + yield* fs.makeDirectory(`${staleOnlyDir}.lock`); + const locked = yield* run(process.execPath, [launcher, "--version"], { + cwd: root, + env: { ...env, T3CODE_HOME: staleOnlyHome }, + }); + assert.equal(locked.exitCode, 1); + assert.include(locked.stderr, `delete ${staleOnlyDir}.lock`); + assert.equal(yield* fs.readFileString(path.join(staleOnlyDir, "t3")), "incomplete"); + + // Without a peer, the stale folder is replaced by our own download. + yield* fs.remove(`${staleOnlyDir}.lock`, { recursive: true }); + const replaced = yield* run(process.execPath, [launcher, "--version"], { + cwd: root, + env: { ...env, T3CODE_HOME: staleOnlyHome }, + }); + assert.equal( + replaced.stdout.trim(), + `stub --version launcher=${launcher}`, + replaced.stderr, + ); + assert.equal(replaced.exitCode, 7); + assert.deepStrictEqual( + yield* fs.readDirectory(path.join(staleOnlyHome, "runtime/versions")), + [VERSION], + ); + release.corruptChecksum(); const corrupt = yield* run(process.execPath, [launcher, "--version"], { cwd: root, diff --git a/scripts/npm-launcher/t3.mjs b/scripts/npm-launcher/t3.mjs index 4973cf10a423a..f8828210862e3 100644 --- a/scripts/npm-launcher/t3.mjs +++ b/scripts/npm-launcher/t3.mjs @@ -109,11 +109,36 @@ const isComplete = (dir) => { function publish(staging, dir) { try { fs.renameSync(staging, dir); + return; } catch (error) { if (isComplete(dir)) return; if (!fs.existsSync(dir)) throw error; - fs.rmSync(dir, { recursive: true, force: true }); - fs.renameSync(staging, dir); + } + const lock = `${dir}.lock`; + try { + fs.mkdirSync(lock); + } catch (error) { + if (error.code !== "EEXIST") throw error; + throw new Error( + `another t3 is installing ${version}. If none is running, delete ${lock} and try again.`, + { cause: error }, + ); + } + const stale = `${staging}.stale`; + try { + // A peer may have published between our marker check and taking the lock. + if (isComplete(dir)) return; + // Move it aside rather than deleting in place: once the folder is empty, a + // peer's first rename can land in it. + fs.renameSync(dir, stale); + try { + fs.renameSync(staging, dir); + } catch (error) { + if (!isComplete(dir)) throw error; + } + } finally { + fs.rmSync(lock, { recursive: true, force: true }); + fs.rmSync(stale, { recursive: true, force: true }); } } From e836ab1275cdb17494e708ad34447071b472b82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Jiron=20=C2=B7=20MarMarLabs?= Date: Thu, 29 Oct 2026 13:06:08 -0500 Subject: [PATCH 80/86] fix(web): right panel launcher letters work on non-Latin keyboard layouts (#21508) Co-authored-by: maria-rcks --- .../src/components/RightPanelTabs.test.tsx | 69 +++++++++++++++++++ apps/web/src/components/RightPanelTabs.tsx | 9 +-- .../components/chat/composerFocusRedirect.ts | 12 ++-- .../chat/useChatShortcuts.browser.test.tsx | 40 ++++++++++- apps/web/src/lib/surfaceShortcutKey.ts | 14 ++++ 5 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/lib/surfaceShortcutKey.ts diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index fa9cbf0141c3e..88793cce44850 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -26,8 +26,62 @@ describe("surface shortcuts", () => { const actions = [ { shortcut: "B", available: true, label: "Browser" }, { shortcut: "D", available: false, label: "Diff" }, + { shortcut: "T", available: true, label: "Terminal" }, + { shortcut: "M", available: true, label: "Device" }, ] as const; + it("falls back to the physical key when the layout types a non-Latin letter or mark", () => { + // Ukrainian layout: the B key types "и". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0438", { code: "KeyB" }))).toBe( + actions[0], + ); + // Greek layout: the B key types "β". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u03b2", { code: "KeyB" }))).toBe( + actions[0], + ); + // Thai layout: the B key types the vowel sign "\u0e34", a combining mark. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0e34", { code: "KeyB" }))).toBe( + actions[0], + ); + // Arabic (101) layout: the B key types lam alef "\u0644\u0627", two code points. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0644\u0627", { code: "KeyB" })), + ).toBe(actions[0]); + // Ukrainian layout: the D key types "\u0432", but the Diff surface is unavailable. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0432", { code: "KeyD" })), + ).toBeNull(); + }); + + it("follows the typed letter on Latin layouts that move it", () => { + // Dvorak: the key at QWERTY B types "x", and "b" sits on QWERTY N. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("x", { code: "KeyB" }))).toBeNull(); + // The same key with Shift or Caps Lock types "X". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("X", { code: "KeyB" }))).toBeNull(); + expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyN" }))).toBe( + actions[0], + ); + }); + + it("keeps accented Latin letters typed on a letter key", () => { + // Bépo: the T key types "è". + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u00e8", { code: "KeyT" })), + ).toBeNull(); + }); + + it("keeps punctuation typed on a letter key", () => { + // French AZERTY: the M key types ",". + expect(surfaceShortcutActionForKey(actions, shortcutEvent(",", { code: "KeyM" }))).toBeNull(); + }); + + it("keeps a dead key on a letter key", () => { + // A dead key starts an accented letter on a Latin layout. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("Dead", { code: "KeyT" })), + ).toBeNull(); + }); + it("leaves modified, composing, and already-handled key events alone", () => { expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull(); expect( @@ -37,6 +91,21 @@ describe("surface shortcuts", () => { surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })), ).toBeNull(); }); + + it("leaves AltGr text alone unless it is a plain letter", () => { + const getModifierState = (key: string) => key === "AltGraph"; + // German layout: AltGr+M types "\u00b5" and sets only AltGraph on Linux and Windows. + expect( + surfaceShortcutActionForKey( + actions, + shortcutEvent("\u00b5", { code: "KeyM", getModifierState }), + ), + ).toBeNull(); + // Finnish layout: AltGr+B types "b", which still matches, as it does in keybindings.ts. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyB", getModifierState })), + ).toBe(actions[0]); + }); }); describe("surface shortcut typing contexts", () => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index dd62a684e92e4..d74e03ea842ab 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -92,6 +92,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; export interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -238,17 +239,17 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { type SurfaceShortcutEvent = Pick< KeyboardEvent, "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" ->; +> & + Partial>; export function surfaceShortcutActionForKey< const Action extends { available: boolean; shortcut: string }, >(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { if (event.defaultPrevented || event.isComposing) return null; if (event.metaKey || event.ctrlKey || event.altKey) return null; + const key = surfaceShortcutKey(event); return ( - actions.find( - (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), - ) ?? null + actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null ); } diff --git a/apps/web/src/components/chat/composerFocusRedirect.ts b/apps/web/src/components/chat/composerFocusRedirect.ts index 9f791f944ea3e..f7730b367925e 100644 --- a/apps/web/src/components/chat/composerFocusRedirect.ts +++ b/apps/web/src/components/chat/composerFocusRedirect.ts @@ -1,3 +1,5 @@ +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; + export const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", "textarea", @@ -48,11 +50,13 @@ const SURFACE_LAUNCHER_KEYS_ATTRIBUTE = "data-surface-launcher-keys"; // The right-panel launcher registers its own window capture listener when it // mounts. When the panel opens after page load that listener runs after ours, // so we must leave its letters alone or the composer takes them first. -function isSurfaceLauncherKey(key: string): boolean { +function isSurfaceLauncherKey(event: KeyboardEvent): boolean { const launcherKeys = document .querySelector(`[${SURFACE_LAUNCHER_KEYS_ATTRIBUTE}]`) - ?.getAttribute(SURFACE_LAUNCHER_KEYS_ATTRIBUTE); - return launcherKeys?.toLowerCase().includes(key.toLowerCase()) ?? false; + ?.getAttribute(SURFACE_LAUNCHER_KEYS_ATTRIBUTE) + ?.toLowerCase(); + const key = surfaceShortcutKey(event); + return key !== null && (launcherKeys?.includes(key) ?? false); } export function shouldRedirectInputToComposer(event: Event): boolean { @@ -70,7 +74,7 @@ export function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.metaKey || event.ctrlKey || event.altKey) return false; if (event.key.length !== 1) return false; if (!shouldRedirectInputToComposer(event)) return false; - if (isSurfaceLauncherKey(event.key)) return false; + if (isSurfaceLauncherKey(event)) return false; return true; } diff --git a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx index b601de299dac5..64f56007ceaaa 100644 --- a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx +++ b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { expect, it } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; import { render } from "../../../test/render"; import type { ChatComposerHandle } from "./ChatComposer"; import { useChatShortcuts } from "./useChatShortcuts"; @@ -25,7 +26,8 @@ function Launcher(props: { keys: string; received: string[] }) { useEffect(() => { const claim = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (!props.keys.toLowerCase().split("").includes(event.key.toLowerCase())) return; + const key = surfaceShortcutKey(event); + if (key === null || !props.keys.toLowerCase().includes(key)) return; event.preventDefault(); props.received.push(event.key); }; @@ -94,3 +96,39 @@ it("still types other letters into the composer while the launcher is open", asy expect(composerKeys).toEqual(["a"]); expect(received).toEqual([]); }); + +it("leaves a non-Latin launcher letter to the launcher by its physical key", async () => { + const composerKeys: string[] = []; + const received: string[] = []; + await using screen = await render(, options); + await screen.rerender(); + + // Playwright cannot press a key its US layout lacks, so send what a Ukrainian T key sends. + const event = new KeyboardEvent("keydown", { + key: "\u0435", + code: "KeyT", + bubbles: true, + cancelable: true, + }); + (document.activeElement ?? document.body).dispatchEvent(event); + + expect(composerKeys).toEqual([]); + expect(received).toEqual(["\u0435"]); +}); + +it.each([ + { key: "b", code: "KeyN", composer: [], launcher: ["b"] }, + { key: "x", code: "KeyB", composer: ["x"], launcher: [] }, + { key: "è", code: "KeyT", composer: ["è"], launcher: [] }, +])("routes $key on $code by the typed letter", async ({ key, code, composer, launcher }) => { + const composerKeys: string[] = []; + const received: string[] = []; + await using screen = await render(, options); + await screen.rerender(); + + const event = new KeyboardEvent("keydown", { key, code, bubbles: true, cancelable: true }); + (document.activeElement ?? document.body).dispatchEvent(event); + + expect(composerKeys).toEqual(composer); + expect(received).toEqual(launcher); +}); diff --git a/apps/web/src/lib/surfaceShortcutKey.ts b/apps/web/src/lib/surfaceShortcutKey.ts new file mode 100644 index 0000000000000..98c4d6660da11 --- /dev/null +++ b/apps/web/src/lib/surfaceShortcutKey.ts @@ -0,0 +1,14 @@ +type SurfaceKeyEvent = Pick & + Partial>; + +export function surfaceShortcutKey(event: SurfaceKeyEvent): string | null { + // AltGr text must stay text even when the browser omits Ctrl and Alt. + if (event.getModifierState?.("AltGraph") && !/^[a-z0-9]$/i.test(event.key)) return null; + const typed = event.key.toLowerCase(); + // Preserve Latin layouts, punctuation and dead keys; non-Latin letters/marks use the physical key. + const physical = + /^[\p{L}\p{M}]+$/u.test(typed) && !/\p{Script=Latin}/u.test(typed) + ? event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase() + : undefined; + return physical ?? typed; +} From 5b4f2aa1532730d4dd51d2cb48a57ff74a0f54eb Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Thu, 29 Oct 2026 14:06:30 -0400 Subject: [PATCH 81/86] fix(server): report a deleted or replaced ownership lock (#21886) Co-authored-by: maria-rcks --- apps/server/src/serverOwnership.ts | 54 +++++++++++-- apps/server/src/serverRuntimeState.test.ts | 91 ++++++++++++++++++++++ 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 1959735425515..6d89f45cb792e 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -44,7 +44,7 @@ export class ServerAlreadyRunningError extends Schema.TaggedError new ServerOwnershipError({ stateDir, cause }))); - yield* Effect.acquireRelease( + const lockPath = NodePath.join(stateDir, "server-owner.sqlite"); + const lock = yield* Effect.acquireRelease( Effect.try({ try: () => { NodeFS.mkdirSync(stateDir, { recursive: true }); - const db = new NodeSqlite.DatabaseSync(NodePath.join(stateDir, "server-owner.sqlite")); + let before = NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false }); + if (before === undefined) { + NodeFS.closeSync(NodeFS.openSync(lockPath, "a", 0o644)); + before = NodeFS.statSync(lockPath, { bigint: true }); + } + const db = new NodeSqlite.DatabaseSync(lockPath); try { db.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;"); + const identity = NodeFS.statSync(lockPath, { bigint: true }); + return { + db, + identity, + replacedDuringAcquire: before.dev !== identity.dev || before.ino !== identity.ino, + }; } catch (cause) { db.close(); throw cause; } - return db; }, catch: (cause) => isSqliteBusy(cause) ? new ServerAlreadyRunningError({ stateDir }) : new ServerOwnershipError({ stateDir, cause }), }), - (db) => + ({ db }) => readPersistedServerRuntimeState(statePath).pipe( Effect.flatMap((state) => Option.isSome(state) && state.value.ownerId === ownerId @@ -233,6 +244,39 @@ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(functi return yield* new OlderBootServiceError({ baseDir: olderServiceHome }); } + yield* Effect.gen(function* () { + let lost = lock.replacedDuringAcquire; + let statWarningLogged = false; + while (!lost) { + yield* Effect.sleep("5 seconds"); + // Closing another descriptor for this inode releases SQLite's POSIX locks. Stat by path only. + const current = yield* Effect.sync(() => { + try { + return NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false }); + } catch (cause) { + return cause instanceof Error && "code" in cause ? String(cause.code) : "unknown"; + } + }); + if (typeof current === "string") { + if (!statWarningLogged) { + yield* Effect.logWarning( + `Cannot check server ownership lock at ${lockPath} (${current}).`, + ); + statWarningLogged = true; + } + continue; + } + statWarningLogged = false; + lost = + current === undefined || + current.dev !== lock.identity.dev || + current.ino !== lock.identity.ino; + } + yield* Effect.logError( + `Server ownership lock at ${lockPath} was deleted or replaced. Another server may now share this T3 home. Stop any other server using this home, then restart this server. Do not delete server-owner.sqlite while a server is running.`, + ); + }).pipe(Effect.forkScoped); + return { publish: (state: PersistedServerRuntimeState) => persistServerRuntimeState({ path: statePath, state: { ...state, ownerId } }), diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index abafbc7e24f62..b75ab72594add 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -1,13 +1,20 @@ +// @effect-diagnostics nodeBuiltinImport:off - Injects a real file replacement during the native SQLite transaction. +import * as NodeFS from "node:fs"; +import * as NodeSqlite from "node:sqlite"; + import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Runtime from "effect/Runtime"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts"; @@ -179,6 +186,7 @@ describe("server ownership", () => { Effect.flip, ); assert.equal(refused._tag, "ServerAlreadyRunningError"); + assert.include(refused.message, "do not delete it while a server is running"); assert.equal(yield* fs.readFileString(statePath), before); }), ); @@ -187,6 +195,89 @@ describe("server ownership", () => { }).pipe(Effect.provide(layerOwnership)), ); + describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("lock file checks", () => { + it.effect.each([ + "unchanged", + "unlink", + "replace", + "during acquisition", + "first acquisition", + // Root bypasses directory permissions, so chmod cannot force EACCES. + ...(process.getuid?.() === 0 ? [] : (["stat error"] as const)), + ] as const)("monitors ownership after %s", (operation) => { + const messages: unknown[] = []; + const warnings: unknown[] = []; + const logger = Logger.make(({ message, logLevel }) => + (logLevel === "Warn" ? warnings : messages).push(message), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-lost-" }); + const statePath = path.join(root, "server-runtime.json"); + const lockPath = path.join(root, "server-owner.sqlite"); + const replacement = path.join(root, "replacement.sqlite"); + if (operation === "during acquisition" || operation === "first acquisition") { + if (operation === "during acquisition") yield* fs.writeFileString(lockPath, ""); + yield* fs.writeFileString(replacement, ""); + const exec = NodeSqlite.DatabaseSync.prototype.exec; + yield* Effect.acquireRelease( + Effect.sync(() => + vi.spyOn(NodeSqlite.DatabaseSync.prototype, "exec").mockImplementationOnce(function ( + this: NodeSqlite.DatabaseSync, + sql: string, + ) { + exec.call(this, sql); + NodeFS.renameSync(replacement, lockPath); + }), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + } + yield* ServerOwnership.acquireServerOwnership(statePath); + if (operation === "unlink") { + yield* fs.remove(lockPath); + } else if (operation === "replace") { + yield* fs.writeFileString(replacement, ""); + yield* fs.rename(replacement, lockPath); + } + if (operation === "stat error") { + yield* fs.chmod(root, 0); + yield* TestClock.adjust("15 seconds").pipe( + Effect.ensuring(fs.chmod(root, 0o700).pipe(Effect.orDie)), + ); + assert.deepEqual(warnings, [ + [`Cannot check server ownership lock at ${lockPath} (EACCES).`], + ]); + yield* TestClock.adjust("5 seconds"); + assert.deepEqual(messages, []); + yield* fs.remove(lockPath); + } + yield* TestClock.adjust("5 seconds"); + const expected = + operation === "unchanged" + ? [] + : [ + [ + `Server ownership lock at ${lockPath} was deleted or replaced. Another server may now share this T3 home. Stop any other server using this home, then restart this server. Do not delete server-owner.sqlite while a server is running.`, + ], + ]; + assert.deepEqual(messages, expected); + if (operation !== "unchanged") { + yield* fs.remove(`${lockPath}-journal`, { force: true }); + yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath)); + } + yield* TestClock.adjust("15 seconds"); + assert.deepEqual(messages, expected); + assert.lengthOf(warnings, operation === "stat error" ? 1 : 0); + }).pipe( + Effect.provide( + Layer.merge(layerOwnership, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + }); + it.effect("refuses beside a live pre-lock server and ignores a stale record", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; From cb5fac907a21576e1e4b4da24aa70e0e2ea2e8d9 Mon Sep 17 00:00:00 2001 From: fixfon <12512757+fixfon@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:06:53 +0300 Subject: [PATCH 82/86] fix(server): mark unread no longer brings back a dismissed wake (#24080) Co-authored-by: maria-rcks --- .../Orchestrator.control-reads.test.ts | 114 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 15 ++- .../src/state/threadVisits.test.ts | 18 +++ .../client-runtime/src/state/threadVisits.ts | 3 +- 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index c36dfc365af60..61515a053ac36 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -260,6 +260,120 @@ it.effect("marks a never-visited thread's first completion unread", () => }).pipe(Effect.provide(layerTest)), ); +const completeAndSnoozeThread = Effect.fn("test.completeAndSnoozeThread")(function* ( + name: string, + snoozeMinutes: number, +) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make(`thread:${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-${name}`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: "Snoozed completion", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`dispatch-${name}`), + threadId, + messageId: MessageId.make(`dispatch-${name}`), + text: "Run in the background", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`interrupt-${name}`), + threadId, + runId: (yield* projections.getThreadProjection(threadId)).runs[0]!.id, + }); + const completedAt = (yield* projections.getThreadProjection(threadId)).runs.at(-1)?.completedAt; + assert.ok(completedAt); + yield* TestClock.adjust("1 second"); + const snoozedUntil = DateTime.add(yield* DateTime.now, { minutes: snoozeMinutes }); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`snooze-${name}`), + threadId, + snoozedUntil: DateTime.formatIso(snoozedUntil), + }); + return { threadId, completedAt, snoozedUntil }; +}); + +it.effect("mark unread retires an expired snooze so a dismissed wake stays dismissed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, completedAt, snoozedUntil } = yield* completeAndSnoozeThread( + "mark-unread-expired-snooze", + 1, + ); + yield* TestClock.adjust("2 minutes"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make("dismiss-woke"), + threadId, + visitedAt: DateTime.formatIso(snoozedUntil), + }); + const dismissed = yield* projections.getThread(threadId); + assert.deepEqual(dismissed.lastVisitedAt, snoozedUntil); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make("mark-unread-expired-snooze"), + threadId, + }); + assert.deepEqual(yield* projections.getThread(threadId), { + ...dismissed, + lastVisitedAt: DateTime.subtract(completedAt, { milliseconds: 1 }), + snoozedUntil: null, + snoozedAt: null, + lastSnoozeWakeAt: snoozedUntil, + }); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("mark unread leaves a future snooze untouched", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, completedAt } = yield* completeAndSnoozeThread( + "mark-unread-future-snooze", + 60, + ); + yield* TestClock.adjust("1 minute"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make("visit-snoozed"), + threadId, + visitedAt: DateTime.formatIso(yield* DateTime.now), + }); + const visited = yield* projections.getThread(threadId); + assert.isNotNull(visited.snoozedUntil); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make("mark-unread-future-snooze"), + threadId, + }); + assert.deepEqual(yield* projections.getThread(threadId), { + ...visited, + lastVisitedAt: DateTime.subtract(completedAt, { milliseconds: 1 }), + }); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "dispatches metadata, queue resume and request controls without hydrating unrelated history", () => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 94de805b46522..76bcc00bbcb4e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3360,8 +3360,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: thread.updatedAt, }; } - case "thread.mark-unread": - return { ...thread, lastVisitedAt: markUnreadVisitedAt }; + case "thread.mark-unread": { + // Retire the wake before rewinding the visit, preserving its settlement timer. + const snoozeExpired = + thread.snoozedUntil != null && + DateTime.toEpochMillis(thread.snoozedUntil) <= DateTime.toEpochMillis(now); + return { + ...thread, + lastVisitedAt: markUnreadVisitedAt, + ...(snoozeExpired + ? { snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: thread.snoozedUntil } + : {}), + }; + } case "thread.metadata.update": { const previousRecovery = thread.limitRecovery?.runId === command.limitRecovery?.runId && diff --git a/packages/client-runtime/src/state/threadVisits.test.ts b/packages/client-runtime/src/state/threadVisits.test.ts index a99417534ea7a..adcac7139e82a 100644 --- a/packages/client-runtime/src/state/threadVisits.test.ts +++ b/packages/client-runtime/src/state/threadVisits.test.ts @@ -111,6 +111,24 @@ it("keeps a mark-unread on the open thread until new activity lands", () => { expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); }); +it("keeps mark unread sticky after an expired wake is retired", () => { + using t = setup("thread-unread-retired-wake", { + updatedAt: at(10), + lastVisitedAt: at(0), + latestRun: { completedAt: at(9) }, + wokeAt: at(15), + }); + t.mount(); + expect(t.visits).toEqual([{ threadId: t.ref.threadId, visitedAt: at(15), opened: true }]); + t.update({ lastVisitedAt: at(15) }); + t.update({ lastVisitedAt: at(8), wokeAt: null }); + vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); + expect(t.visits).toHaveLength(1); + + t.update({ updatedAt: at(30) }); + expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); +}); + it("waits for the shell and records local visits for servers without tracking", () => { using t = setup("thread-local", null); t.mount(); diff --git a/packages/client-runtime/src/state/threadVisits.ts b/packages/client-runtime/src/state/threadVisits.ts index 08d05909a2dad..c08f47ed01436 100644 --- a/packages/client-runtime/src/state/threadVisits.ts +++ b/packages/client-runtime/src/state/threadVisits.ts @@ -115,7 +115,8 @@ export function createThreadVisitTracker(options: ThreadVisitTrackerOptions) { } const lastVisitedAtMs = shell.lastVisitedAt ? Date.parse(shell.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) return; - if (lastDispatchedUpdatedAt === visitedAt) return; + if (lastDispatchedUpdatedAt !== null && Date.parse(lastDispatchedUpdatedAt) >= updatedAtMs) + return; const completedAtMs = shell.latestRun?.completedAt ? Date.parse(shell.latestRun.completedAt) From fa4f3a2925822201e6fe4f11db45c8649383e420 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 29 Oct 2026 18:07:16 +0000 Subject: [PATCH 83/86] fix(mobile): stop refreshes in hidden PR Guide panes (#25486) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: maria-rcks --- .../pull-requests/PullRequestRouteScreen.tsx | 7 +- .../guide/PullRequestGuideScreen.tsx | 10 +- .../guide/useReviewGuide.test.tsx | 130 ++++++++++++++++-- .../pull-requests/guide/useReviewGuide.ts | 4 +- 4 files changed, 137 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 446794800eadb..bd3f04d9d6b4d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -794,7 +794,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps ) : null} - {tab === "guide" ? : null} + {tab === "guide" ? ( + + ) : null} {tab === "activity" ? ( diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx index 764cd5f881ac9..e797a12a57217 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx @@ -3,18 +3,21 @@ import { type NavigationProp, type ParamListBase, } from "@react-navigation/native"; -import { act } from "react"; +import { AsyncResult } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ + enabled: true, + generate: vi.fn(), query: { - data: null as { run: { _tag: "Running" | "Completed" } } | null, + data: null as { run: { _tag: "Running" | "Completed" | "Failed" } | null } | null, isPending: false, refresh: vi.fn<() => void>(), }, otherQuery: { - data: null as { run: { _tag: "Running" | "Completed" } } | null, + data: null as { run: { _tag: "Running" | "Completed" | "Failed" } | null } | null, isPending: false, refresh: vi.fn<() => void>(), }, @@ -33,7 +36,8 @@ vi.mock("@react-navigation/native", async () => { return { NavigationContext, useFocusEffect }; }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => (atom === "settings" ? { reviewGuidesEnabled: true } : true), + useAtomValue: (atom: unknown) => + atom === "settings" ? { reviewGuidesEnabled: mocks.enabled } : true, })); vi.mock("../../../state/server", () => ({ serverEnvironment: { settingsValueAtom: () => "settings" }, @@ -48,7 +52,7 @@ vi.mock("../../../state/pull-requests", () => ({ vi.mock("../../../state/query", () => ({ useEnvironmentQuery: (number: number) => ({ ...(number === 1 ? mocks.query : mocks.otherQuery) }), })); -vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.generate })); vi.mock("../usePullRequestConnected", () => ({ usePullRequestConnected: () => true })); vi.mock("../usePullRequestDiff", () => ({ pullRequestTarget: (params: unknown) => params, @@ -78,6 +82,8 @@ function createNavigation() { function setup() { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.enabled = true; + mocks.generate = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); mocks.query = { data: null, isPending: false, refresh: vi.fn() }; mocks.otherQuery = { data: null, isPending: false, refresh: vi.fn() }; const root = createRoot(document.createElement("div")); @@ -88,20 +94,35 @@ function setup() { repository: "repo", number: 1, }; - function Probe({ number, environmentId }: { number: number; environmentId: string }) { - useReviewGuide({ ...params, number, environmentId }); + let current: ReturnType; + function Probe({ + number, + environmentId, + visible, + }: { + number: number; + environmentId: string; + visible?: boolean; + }) { + const state = useReviewGuide({ ...params, number, environmentId }, visible); + useLayoutEffect(() => { + current = state; + }); return null; } return { - render(number = 1, environmentId = params.environmentId) { + render(number = 1, environmentId = params.environmentId, visible?: boolean) { act(() => root.render( - + , ), ); }, + get current() { + return current; + }, focus: navigation.focus, [Symbol.dispose]() { act(() => root.unmount()); @@ -188,3 +209,94 @@ it("retires the old environment's polling on target change and clears polling on expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); + +it("suspends only manual refresh while hidden, including focus and target changes", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + { + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + mocks.otherQuery.data = { run: { _tag: "Running" } }; + screen.render(); + const cached = screen.current.data; + screen.render(1, "environment", false); + act(() => vi.advanceTimersByTime(60_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(screen.current.data).toBe(cached); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + screen.render(1, "environment", false); + screen.focus(false); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + + screen.render(2, "other-environment", false); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.otherQuery.refresh).not.toHaveBeenCalled(); + screen.focus(false); + screen.render(2, "other-environment", true); + expect(mocks.otherQuery.refresh).not.toHaveBeenCalled(); + screen.focus(true); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + } + expect(vi.getTimerCount()).toBe(0); +}); + +it("keeps disabled, completed and failed guides idle and refreshes once on reveal", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + mocks.enabled = false; + screen.render(); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.query.refresh).not.toHaveBeenCalled(); + mocks.enabled = true; + mocks.query.data = { run: null }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.render(1, "environment", false); + mocks.query.data = { run: { _tag: "Failed" } }; + screen.render(1, "environment", false); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); +}); + +it("leaves generation running when hidden and accepts its completion without a refresh", async () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + const generated = Promise.withResolvers>>(); + mocks.generate.mockReturnValue(generated.promise); + screen.render(); + let writing: Promise; + act(() => { + writing = screen.current.write(); + }); + expect(screen.current.running).toBe(true); + screen.render(1, "environment", false); + const beforeHidden = mocks.query.refresh.mock.calls.length; + act(() => vi.advanceTimersByTime(60_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden); + expect(mocks.generate).toHaveBeenCalledTimes(1); + expect(screen.current.running).toBe(true); + await act(async () => { + generated.resolve(AsyncResult.success(undefined)); + await writing; + }); + expect(screen.current.running).toBe(false); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden + 1); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts index 5dad083e76108..bbdb6caee00c3 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts @@ -17,7 +17,7 @@ export function useReviewGuidesEnabled(params: PullRequestParams) { return settings?.reviewGuidesEnabled === true; } -export function useReviewGuide(params: PullRequestParams) { +export function useReviewGuide(params: PullRequestParams, visible = true) { const target = pullRequestTarget(params); const enabled = useReviewGuidesEnabled(params); const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null); @@ -35,7 +35,7 @@ export function useReviewGuide(params: PullRequestParams) { const running = starting || query.data?.run?._tag === "Running"; const refresh = query.refresh; useFocusEffect(() => { - if (!enabled) return; + if (!enabled || !visible) return; refresh(); if (!running) return; const timer = setInterval(refresh, 5_000); From b6fbd181cae49d38927360913851121cc35ec4ff Mon Sep 17 00:00:00 2001 From: Gourav Kumar Dhiman Date: Thu, 29 Oct 2026 23:54:19 +0530 Subject: [PATCH 84/86] fix(desktop): installing the t3 command no longer reports its own launcher as another t3 (#22375) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../desktop/src/app/DesktopCliCommand.test.ts | 36 +++++++++++++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 30 ++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 7141ebde70537..8566613dd6069 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -136,6 +136,14 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* command.uninstall; expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + + const launcher = path.join(home, ".t3", "bin", "t3"); + yield* fs.writeFileString(launcher, "npm's t3\n"); + process.env.PATH = path.dirname(launcher); + expect(yield* command.state).toMatchObject({ onPath: false, shadowedBy: launcher }); + expect((yield* Effect.flip(command.install)).message).toContain("Could not set up"); + yield* command.uninstall; + expect(yield* fs.readFileString(launcher)).toBe("npm's t3\n"); }).pipe(Effect.scoped), ); @@ -217,6 +225,34 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); + it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const launcher = path.join(home, ".t3", "bin", "t3"); + process.env.PATH = [path.dirname(launcher), path.join(home, ".local", "bin")].join(":"); + + const command = yield* commandIn({ home }); + const installed = yield* command.install; + + expect(yield* fs.exists(launcher)).toBe(true); + expect(installed).toMatchObject({ + onPath: true, + installedPath: path.join(home, ".local", "bin", "t3"), + }); + expect(installed.shadowedBy).toBeUndefined(); + expect(yield* command.state).toEqual(installed); + + const removed = yield* command.uninstall; + expect(removed.installedPath).toBeNull(); + expect(removed.onPath).toBe(true); + expect(yield* command.state).toEqual(removed); + expect(yield* fs.exists(installed.installedPath!)).toBe(false); + expect(yield* fs.exists(launcher)).toBe(true); + }).pipe(Effect.scoped), + ); + it.effect("leaves the Windows PATH alone when it cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 8a89a366814ed..f53895a60a570 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -92,6 +92,19 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); + // PATH can reach the regular launcher directly; removal still requires isOurLink. + const runsOurLauncher = Effect.fn("DesktopCliCommand.runsOurLauncher")( + function* (candidate: string) { + if (yield* isOurLink(candidate)) return true; + const [resolved, ours] = yield* Effect.all([fs.realPath(candidate), fs.realPath(launcher)]); + if (resolved !== ours) return false; + const info = yield* fs.stat(candidate); + if (info.type !== "File" || Number(info.size) > 16_384) return false; + return (yield* fs.readFileString(candidate)).includes(DesktopCliShim.MARKER); + }, + Effect.orElseSucceed(() => false), + ); + const powershell = (env: Record) => Effect.scoped( Effect.gen(function* () { @@ -141,7 +154,7 @@ export const make = Effect.gen(function* () { const foreignFirstOnPath = Effect.gen(function* () { if (windows) return Option.none(); const first = yield* firstOnPath; - if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none(); + if (Option.isNone(first) || (yield* runsOurLauncher(first.value))) return Option.none(); return first; }); @@ -168,12 +181,17 @@ export const make = Effect.gen(function* () { const installed = yield* installedAt; const shadowedBy = yield* foreignFirstOnPath; const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; - if (Option.isNone(installed)) { - return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow }; - } const first = yield* firstOnPath; - const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + const onPath = windows + ? Option.isSome(installed) + : Option.isSome(first) && (yield* runsOurLauncher(first.value)); + return { + supported: true, + installedPath: Option.getOrNull(installed), + onPath, + launcherPath, + ...shadow, + }; }).pipe( Effect.orElseSucceed(() => ({ supported: false, From 1fe8ddd222d127815064d400d1667c88f60cd71a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:28:30 +0000 Subject: [PATCH 85/86] test(mobile): load the branch occupancy test without native sources (#27063) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/git/GitBranchesSheet.occupancy.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx index 7667b0c793fc4..e611b95d3f683 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -30,6 +30,8 @@ vi.mock("../../../components/ui/AppText", () => ({ AppTextInput: () => null, })); vi.mock("./gitSheetComponents", () => ({ SheetActionButton: () => null })); +vi.mock("../../../state/entities", () => ({})); +vi.mock("../../../state/threads", () => ({})); vi.mock("../../../state/use-thread-selection", () => ({ useThreadSelection: () => ({ selectedThread: { environmentId: "environment-1", worktreePath: checkout.worktreePath }, From 08a463e29976617134de01e9341f898bc881d0eb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 13:34:43 -0500 Subject: [PATCH 86/86] fix(settings): project edits keep overrides the client does not know (#24472) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/serverSettings.test.ts | 25 +++++++++++++++ apps/server/src/serverSettings.ts | 11 ++++--- packages/contracts/src/settings.test.ts | 22 +++++++++++++ packages/contracts/src/settings.ts | 27 ++++++++++++---- packages/shared/src/serverSettings.test.ts | 37 ++++++++++++++++++++++ packages/shared/src/serverSettings.ts | 16 ++++++++-- 6 files changed, 125 insertions(+), 13 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 29dea381e759a..f34b653c8cbc8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -212,6 +212,31 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("keeps an override key a newer server saved when a project setting is edited", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("project-a"); + yield* fs.writeFileString( + config.settingsPath, + `{ "projectSettingsOverrides": { "project-a": { "defaultAutoPull": true, "addedByNewerServer": 3 } } }`, + ); + const loaded = yield* service.getSettings; + + yield* service.updateSettings({ + projectSettingsOverrides: { + [projectId]: { ...loaded.projectSettingsOverrides[projectId], defaultAutoPull: false }, + }, + }); + + const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(persisted.projectSettingsOverrides, { + [projectId]: { defaultAutoPull: false, addedByNewerServer: 3 }, + }); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("moves an idle compaction mode saved in the legacy switch to its own field", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index e386b95cf506c..6f6a1db5a292a 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -3,6 +3,7 @@ import { ModelSelection, ProjectId, ProjectScript, + keepUnknownOverrideKeys, ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceMutation, @@ -292,10 +293,12 @@ const ServerSettingsJson = fromLenientJson( ), projectSettingsOverrides: Schema.Record( ProjectId, - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), - }), + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), + }), + ), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), }), ); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 6088cf7a2fa2a..9d765ccee92c6 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -25,6 +25,7 @@ const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); describe("storage cleanup settings", () => { @@ -372,6 +373,27 @@ it("validates remote device hosts and rejects ambiguous host ids", () => { expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); }); +describe("ServerSettings project overrides across versions", () => { + it("sends back an override key from a newer server when an older client edits the entry", () => { + const projectId = ProjectId.make("project-a"); + // As an older client reads a newer server's settings, with one key it does not know. + const entry = decodeServerSettings({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, addedByNewerServer: { mode: "kept" } }, + }, + }).projectSettingsOverrides[projectId]; + + const sent = encodeServerSettingsPatch({ + projectSettingsOverrides: { [projectId]: { ...entry, defaultAutoPull: false } }, + }); + + expect(decodeServerSettingsPatch(sent).projectSettingsOverrides?.[projectId]).toEqual({ + defaultAutoPull: false, + addedByNewerServer: { mode: "kept" }, + }); + }); +}); + describe("resolveProjectProviderInstanceEnabled", () => { const projectId = "project-a" as ProjectId; const instanceId = ProviderInstanceId.make("codex_work"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c7d61aeb5ee21..179e58b7a0b56 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1137,6 +1137,16 @@ export const ProjectSettingsOverrides = Schema.Struct({ } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; +// Whole-entry writers must round-trip unknown keys; keep the type closed to catch misspellings. +export const keepUnknownOverrideKeys = >( + entry: Entry, +): Schema.Codec< + Entry["Type"], + Entry["Encoded"], + Entry["DecodingServices"], + Entry["EncodingServices"] +> => Schema.StructWithRest(entry, [Schema.Record(Schema.String, Schema.Unknown)]); + /** True if null stores an override; otherwise null removes it. */ export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean { return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key); @@ -1263,9 +1273,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), - projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - ), + projectSettingsOverrides: Schema.Record( + ProjectId, + keepUnknownOverrideKeys(ProjectSettingsOverrides), + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), /** Fold once so restarts cannot undo later UI resets. */ projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1626,10 +1637,12 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record( ProjectId, Schema.NullOr( - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), - }), + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), + }), + ), ), ), ), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index bd315271c78e1..5a02f302826de 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -6,10 +6,13 @@ import { ProviderInstanceId, SecretProviderId, SecretProviderKind, + ServerSettings, + ServerSettingsPatch, UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; @@ -24,6 +27,9 @@ import { resolveSourceControlWriterModelSelection, } from "./serverSettings.ts"; +const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); +const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); + const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { @@ -499,6 +505,37 @@ describe("serverSettings helpers", () => { }); }); + // regression: #18334 a newer client must still see this server drop a key it does not know + it("drops an override key this server does not know from a patch and keeps one it stored", () => { + const projectId = ProjectId.make("project-a"); + const current = decodeServerSettings({ + projectSettingsFolded: true, + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, addedByNewerServer: 3 }, + }, + }); + const patch = decodeServerSettingsPatch({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: false, addedByNewerServer: 4, addedByNewerClient: "x" }, + }, + }); + + const updated = applyServerSettingsPatch(current, patch); + expect(updated.projectSettingsOverrides[projectId]).toEqual({ + defaultAutoPull: false, + addedByNewerServer: 3, + }); + const reset = applyServerSettingsPatch(updated, { + projectSettingsOverrides: { [projectId]: {} }, + }); + expect(reset.projectSettingsOverrides[projectId]).toEqual({ addedByNewerServer: 3 }); + expect( + applyServerSettingsPatch(reset, { + projectSettingsOverrides: { [projectId]: null }, + }).projectSettingsOverrides[projectId], + ).toBeUndefined(); + }); + it("clears a project's providerInstances override when a patch entry sends an empty map", () => { const instanceId = ProviderInstanceId.make("codex_work"); const projectId = ProjectId.make("project-a"); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 11eecc6fe3df8..e936c9f83acba 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -14,7 +14,7 @@ import { type ModelSelection, type ProjectId, type ProjectScopedServerSettingKey, - type ProjectSettingsOverrides, + ProjectSettingsOverrides, ProviderDriverKind, ProviderInstanceId, type ServerProvider, @@ -226,6 +226,16 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); + +function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { + return Object.fromEntries( + Object.entries(entry ?? {}).filter( + ([key]) => knownProjectOverrideKeys.has(key) === known, + ), + ); +} + // Older clients omit providerInstances during whole-entry replacement; preserve it to avoid wiping overrides. function mergeProjectSettingsOverridesPatch( current: Readonly>, @@ -239,7 +249,9 @@ function mergeProjectSettingsOverridesPatch( } const storedProviderInstances = current[projectId]?.providerInstances; next.set(projectId, { - ...entry, + // Preserve downgraded storage, but reject new unknown writes so clients can detect unsupported settings. + ...pickOverrideKeys(current[projectId], false), + ...pickOverrideKeys(entry, true), ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined ? {} : { providerInstances: storedProviderInstances }),