diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
index 77a2a6427e1d..ac00a91e91fe 100644
--- a/apps/mobile/eas.json
+++ b/apps/mobile/eas.json
@@ -63,7 +63,7 @@
"v2-preview": {
"extends": "production",
"android": {
- "track": "alpha",
+ "track": "v2",
"releaseStatus": "completed"
}
},
diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css
index 2a1ce3629306..82e7ad85d6cf 100644
--- a/apps/mobile/generated-uniwind-themes.css
+++ b/apps/mobile/generated-uniwind-themes.css
@@ -92,6 +92,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -102,6 +104,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -125,6 +128,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -137,6 +142,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -244,6 +251,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -254,6 +263,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -277,6 +287,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -289,6 +301,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -396,6 +410,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -406,6 +422,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -429,6 +446,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -441,6 +460,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -548,6 +569,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -558,6 +581,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -581,6 +605,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -593,6 +619,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -700,6 +728,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -710,6 +740,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -733,6 +764,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -745,6 +778,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -852,6 +887,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -862,6 +899,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -885,6 +923,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -897,6 +937,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -1004,6 +1046,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -1014,6 +1058,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -1037,6 +1082,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -1049,6 +1096,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -1156,6 +1205,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -1166,6 +1217,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -1189,6 +1241,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -1201,6 +1255,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -1308,6 +1364,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -1318,6 +1376,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -1341,6 +1400,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -1353,6 +1414,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -1460,6 +1523,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -1470,6 +1535,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -1493,6 +1559,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -1505,6 +1573,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
@@ -1612,6 +1682,8 @@
--color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
--color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%);
--color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
--color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
@@ -1622,6 +1694,7 @@
--color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
--color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
--color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
@@ -1645,6 +1718,8 @@
--color-adaptive-neutral-600-300: oklch(43.9% 0 none);
--color-adaptive-neutral-600-400: oklch(43.9% 0 none);
--color-adaptive-neutral-950-50: oklch(14.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%);
--color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
--color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
--color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
@@ -1657,6 +1732,8 @@
--color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
--color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%);
--color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
--color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
@@ -1764,6 +1841,8 @@
--color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
--color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
--color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%);
+ --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%);
--color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
--color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
--color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
@@ -1774,6 +1853,7 @@
--color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
--color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
--color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%);
--color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
--color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
--color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
@@ -1797,6 +1877,8 @@
--color-adaptive-neutral-600-300: oklch(87% 0 none);
--color-adaptive-neutral-600-400: oklch(70.8% 0 none);
--color-adaptive-neutral-950-50: oklch(98.5% 0 none);
+ --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%);
+ --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%);
--color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
--color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
--color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
@@ -1809,6 +1891,8 @@
--color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
--color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
--color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%);
+ --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%);
--color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
--color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
--color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift
index 94eb06671558..a108c79fcc08 100644
--- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift
+++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift
@@ -91,6 +91,12 @@ public class T3ComposerEditorModule: Module {
Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in
view.setSpellCheck(spellCheck)
}
+ Prop("submitTitle") { (view: T3ComposerEditorView, title: String) in
+ view.setSubmitTitle(title)
+ }
+ Prop("alternateSubmitTitle") { (view: T3ComposerEditorView, title: String) in
+ view.setAlternateSubmitTitle(title)
+ }
Prop("enterBehavior") { (view: T3ComposerEditorView, behavior: String) in
view.setEnterBehavior(behavior)
}
diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
index bff803f69c00..33150993f003 100644
--- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
+++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
@@ -94,34 +94,43 @@ private final class ComposerTextView: UITextView {
var onPasteText: ((String, NSRange) -> Void)?
var clipboardFragment = ""
var onAttributedMutation: (() -> Void)?
- var onSubmit: (() -> Void)?
+ var onSubmit: ((Bool) -> Void)?
var isReadOnly = false
var textPasteThresholdBytes = 0
var maxInputChars = Int.max
var enterBehavior: ComposerEnterBehavior = .send
+ /// Shortcut HUD titles. JS supplies what the two sends actually do right now
+ /// ("Queue Message" / "Steer Message"), so the iPad Command-hold list names
+ /// the outcome rather than a generic "Send".
+ var submitTitle = "Send Message"
+ var alternateSubmitTitle = "Send Message"
private var bypassTextPasteInterception = false
override var keyCommands: [UIKeyCommand]? {
var commands = super.keyCommands ?? []
guard !isReadOnly, markedTextRange == nil else { return commands }
- let submit = UIKeyCommand(
- input: "\r",
- modifierFlags: .command,
- action: #selector(submitMessage(_:))
- )
- submit.discoverabilityTitle = "Send Message"
- submit.wantsPriorityOverSystemBehavior = true
- commands.append(submit)
+ // The plainer chord always performs the configured follow-up behavior and
+ // the more-modified one performs its opposite, so Command is the "other
+ // way" modifier whichever Return behavior is configured.
if enterBehavior == .send {
let submitOnReturn = UIKeyCommand(
input: "\r",
modifierFlags: [],
action: #selector(submitMessage(_:))
)
- submitOnReturn.discoverabilityTitle = "Send Message"
+ submitOnReturn.discoverabilityTitle = submitTitle
submitOnReturn.wantsPriorityOverSystemBehavior = true
commands.append(submitOnReturn)
+ let submitAlternate = UIKeyCommand(
+ input: "\r",
+ modifierFlags: .command,
+ action: #selector(submitMessageAlternate(_:))
+ )
+ submitAlternate.discoverabilityTitle = alternateSubmitTitle
+ submitAlternate.wantsPriorityOverSystemBehavior = true
+ commands.append(submitAlternate)
+
let newline = UIKeyCommand(
input: "\r",
modifierFlags: .shift,
@@ -130,6 +139,24 @@ private final class ComposerTextView: UITextView {
newline.discoverabilityTitle = "New Line"
newline.wantsPriorityOverSystemBehavior = true
commands.append(newline)
+ } else {
+ let submit = UIKeyCommand(
+ input: "\r",
+ modifierFlags: .command,
+ action: #selector(submitMessage(_:))
+ )
+ submit.discoverabilityTitle = submitTitle
+ submit.wantsPriorityOverSystemBehavior = true
+ commands.append(submit)
+
+ let submitAlternate = UIKeyCommand(
+ input: "\r",
+ modifierFlags: [.command, .shift],
+ action: #selector(submitMessageAlternate(_:))
+ )
+ submitAlternate.discoverabilityTitle = alternateSubmitTitle
+ submitAlternate.wantsPriorityOverSystemBehavior = true
+ commands.append(submitAlternate)
}
if textPasteThresholdBytes > 0 {
let pasteAsText = UIKeyCommand(
@@ -146,7 +173,12 @@ private final class ComposerTextView: UITextView {
@objc private func submitMessage(_ sender: UIKeyCommand) {
guard !isReadOnly, markedTextRange == nil else { return }
- onSubmit?()
+ onSubmit?(false)
+ }
+
+ @objc private func submitMessageAlternate(_ sender: UIKeyCommand) {
+ guard !isReadOnly, markedTextRange == nil else { return }
+ onSubmit?(true)
}
@objc private func insertNewline(_ sender: UIKeyCommand) {
@@ -489,8 +521,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
textView.onAttributedMutation = { [weak self] in
self?.emitTextChange()
}
- textView.onSubmit = { [weak self] in
- self?.onComposerSubmit([:])
+ textView.onSubmit = { [weak self] alternate in
+ self?.onComposerSubmit(["alternate": alternate])
}
let contextTap = UITapGestureRecognizer(target: self, action: #selector(openContext(_:)))
contextTap.cancelsTouchesInView = false
@@ -701,6 +733,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
textView.enterBehavior = ComposerEnterBehavior(rawValue: behavior) ?? .send
}
+ func setSubmitTitle(_ title: String) {
+ textView.submitTitle = title
+ }
+
+ func setAlternateSubmitTitle(_ title: String) {
+ textView.alternateSubmitTitle = title
+ }
+
func setTextPasteThresholdBytes(_ threshold: Int) {
textView.textPasteThresholdBytes = threshold
}
diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
index a02a4a587153..75d1e852acf8 100644
--- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
+++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
@@ -19,6 +19,7 @@ const CONTEXT_CHIP_PRESENTATIONS = {
"review-comment": { accent: "#8a70dd", symbol: "text.bubble" },
"pull-request": { accent: "#7079e4", symbol: "git-pull-request" },
skill: { accent: "#b261be", symbol: "cube" },
+ thread: { accent: "#009c96", symbol: "text.bubble" },
} as const;
/**
diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts
index 94643c3e413f..35d5b5f5e3d0 100644
--- a/apps/mobile/scripts/generate-uniwind-themes.mts
+++ b/apps/mobile/scripts/generate-uniwind-themes.mts
@@ -58,6 +58,8 @@ const ADAPTIVE_COLORS: Readonly
(
- {children}
+
+ {children}
+
),
}),
SettingsAuth: createNativeStackScreen({
@@ -481,6 +544,8 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"NewTaskSheet",
"SettingsLegal",
"SettingsSheet",
+ "ThreadAgents",
+ "ThreadQueue",
"ThreadReviewComment",
"ThreadDevicePreview",
"ThreadSettingsSheet",
@@ -696,6 +761,24 @@ const RootStackConfig = createNativeStackNavigator({
}),
},
}),
+ ThreadQueue: createNativeStackScreen({
+ screen: ThreadQueueSheet,
+ options: {
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
+ headerShown: false,
+ sheetAllowedDetents: [0.65, 0.95],
+ sheetGrabberVisible: true,
+ },
+ }),
+ ThreadAgents: createNativeStackScreen({
+ screen: ThreadAgentsSheet,
+ options: {
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
+ headerShown: false,
+ sheetAllowedDetents: [0.5, 0.9],
+ sheetGrabberVisible: true,
+ },
+ }),
GitOverview: createNativeStackScreen({
screen: GitOverviewSheet,
linking: `${THREAD_LINKING_PREFIX}/git`,
diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx
index eef05ee94f40..17eaa1cc718b 100644
--- a/apps/mobile/src/components/AppSymbol.tsx
+++ b/apps/mobile/src/components/AppSymbol.tsx
@@ -13,12 +13,14 @@ import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp";
import IconArrowDown from "@tabler/icons-react-native/IconArrowDown";
import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp";
import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft";
+import IconArrowRight from "@tabler/icons-react-native/IconArrowRight";
import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle";
import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle";
import IconArrowUp from "@tabler/icons-react-native/IconArrowUp";
import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle";
import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight";
import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle";
+import IconArrowsLeftRight from "@tabler/icons-react-native/IconArrowsLeftRight";
import IconArrowsDiagonal2 from "@tabler/icons-react-native/IconArrowsDiagonal2";
import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize";
import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";
@@ -71,6 +73,7 @@ import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebar
import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing";
import IconMicrophone from "@tabler/icons-react-native/IconMicrophone";
import IconLink from "@tabler/icons-react-native/IconLink";
+import IconListNumbers from "@tabler/icons-react-native/IconListNumbers";
import IconMenu2 from "@tabler/icons-react-native/IconMenu2";
import IconMessage from "@tabler/icons-react-native/IconMessage";
import IconMinus from "@tabler/icons-react-native/IconMinus";
@@ -103,6 +106,7 @@ import IconTrash from "@tabler/icons-react-native/IconTrash";
import IconTypography from "@tabler/icons-react-native/IconTypography";
import IconUpload from "@tabler/icons-react-native/IconUpload";
import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
+import IconUsers from "@tabler/icons-react-native/IconUsers";
import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
import IconWorld from "@tabler/icons-react-native/IconWorld";
import IconX from "@tabler/icons-react-native/IconX";
@@ -112,11 +116,14 @@ import { withUniwind } from "uniwind";
const ANDROID_ICON_BY_SF_SYMBOL = {
"arrow.branch": IconGitBranch,
"arrow.left": IconArrowLeft,
+ "arrow.right": IconArrowRight,
+ "arrow.left.arrow.right": IconArrowsLeftRight,
"arrow.clockwise": IconRefresh,
"arrow.down": IconArrowDown,
"arrow.down.circle": IconArrowDownCircle,
"arrow.right.circle": IconArrowRightCircle,
"arrow.triangle.branch": IconGitBranch,
+ "arrow.triangle.merge": IconGitMerge,
"arrow.triangle.pull": IconGitPullRequest,
"square.3.layers.3d": IconStack2,
"arrow.turn.left.up": IconArrowBackUp,
@@ -150,6 +157,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
"chevron.right": IconChevronRight,
"chevron.up": IconChevronUp,
desktopcomputer: IconDeviceDesktop,
+ doc: IconFileText,
"doc.on.doc": IconCopy,
"doc.text": IconFileText,
ellipsis: IconDots,
@@ -170,6 +178,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
keyboard: IconKeyboard,
laptopcomputer: IconDeviceLaptop,
link: IconLink,
+ "list.number": IconListNumbers,
"line.3.horizontal": IconMenu2,
"line.3.horizontal.decrease": IconFilter,
"line.3.horizontal.decrease.circle": IconFilter,
@@ -182,6 +191,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
paintbrush: IconPalette,
pencil: IconPencil,
"person.crop.circle": IconUserCircle,
+ "person.2": IconUsers,
photo: IconPhoto,
pin: IconPin,
"pin.slash": IconPinnedOff,
@@ -228,6 +238,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = {
auto_awesome: IconSparkles,
bolt: IconBolt,
build: IconTool,
+ chat: IconMessage,
chat_bubble: IconMessage,
check: IconCheck,
close: IconX,
@@ -245,6 +256,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = {
lock: IconLock,
more_vert: IconDotsVertical,
merge: IconGitMerge,
+ psychology: IconBrain,
public: IconWorld,
remove: IconMinus,
smartphone: IconDeviceMobile,
diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx
index c8f1d4385517..1643f3f6fdeb 100644
--- a/apps/mobile/src/components/BrandMark.tsx
+++ b/apps/mobile/src/components/BrandMark.tsx
@@ -3,14 +3,9 @@ import { Image } from "expo-image";
import { View } from "react-native";
import { AppText as Text } from "./AppText";
+import { T3_CODE_BRAND_MARK_SOURCE } from "./brandAssets";
const appVariant = Constants.expoConfig?.extra?.appVariant;
-const BRAND_MARK_SOURCE =
- appVariant === "development"
- ? require("../../../../assets/dev/blueprint-ios-1024.png")
- : appVariant === "preview"
- ? require("../../../../assets/nightly/nightly-ios-1024.png")
- : require("../../../../assets/prod/black-ios-1024.png");
const DEFAULT_STAGE_LABEL =
appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha";
@@ -22,7 +17,7 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab
return (
void;
readonly skillDescription?: string;
readonly onOpenSkill?: () => void;
+ readonly onOpenThread?: (thread: ScopedThreadRef) => void;
readonly environmentId?: EnvironmentId;
readonly records?: ReadonlyArray;
readonly attachments?: ReadonlyArray;
@@ -348,6 +350,25 @@ export function ComposerContextSheet(props: {
{record.kind === "mention" ? (
) : null}
+ {record.kind === "thread" ? (
+
+
+ {props.onOpenThread ? (
+
+ props.onOpenThread?.({
+ environmentId: record.environmentId,
+ threadId: record.threadId,
+ })
+ }
+ className="rounded-xl bg-subtle p-4"
+ >
+ Open thread
+
+ ) : null}
+
+ ) : null}
{record.kind === "skill" ? (
diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx
index a697ad6bc11f..a716055b15ca 100644
--- a/apps/mobile/src/components/ComposerEditor.tsx
+++ b/apps/mobile/src/components/ComposerEditor.tsx
@@ -1,6 +1,7 @@
import { ComposerContextId } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
+import { useNavigation } from "@react-navigation/native";
import { useEffect, useMemo, useRef, useState } from "react";
import { Alert } from "react-native";
import type { EnvironmentId } from "@t3tools/contracts";
@@ -152,6 +153,7 @@ export function ComposerEditor({
: "",
[environmentId, draft.context, draft.attachments],
);
+ const navigation = useNavigation();
const selectedReference = selected
? collectComposerContextReferences(selected.source)[0]
: undefined;
@@ -229,6 +231,13 @@ export function ComposerEditor({
},
}
: {})}
+ onOpenThread={(thread) => {
+ setSelected(null);
+ navigation.navigate("Thread", {
+ environmentId: String(thread.environmentId),
+ threadId: String(thread.threadId),
+ });
+ }}
environmentId={environmentId}
records={draft.context?.records}
attachments={draft.attachments}
diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx
index f8a8fb7b66d6..c21f51421d03 100644
--- a/apps/mobile/src/components/ComposerToolbar.tsx
+++ b/apps/mobile/src/components/ComposerToolbar.tsx
@@ -8,6 +8,7 @@ import {
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
+ type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
@@ -231,6 +232,10 @@ export function ComposerActionButton(props: {
readonly icon: ComponentProps["name"];
readonly onPress: () => void;
readonly variant?: "primary" | "danger";
+ // Forwarded so a ControlPillMenu can drive this button as its long-press
+ // anchor: Android injects onLongPress, iOS injects onTouchStart and onPress.
+ readonly onLongPress?: PressableProps["onLongPress"];
+ readonly onTouchStart?: PressableProps["onTouchStart"];
}) {
const { scale, smallIconSize } = useAndroidControlSizing();
const circleSize = Math.round(30 * scale);
@@ -242,6 +247,8 @@ export function ComposerActionButton(props: {
className="size-[44px] shrink-0 items-center justify-center active:opacity-70"
disabled={props.disabled}
onPress={props.onPress}
+ onLongPress={props.onLongPress}
+ onTouchStart={props.onTouchStart}
>
= {
+export const ENVIRONMENT_MACHINE_SYMBOLS = {
server: "server.rack",
cloud: "cloud",
linux: "terminal",
@@ -9,7 +9,7 @@ const SYMBOL_BY_KIND: Record = {
laptop: "laptopcomputer",
"mac-mini": "macmini",
"mac-studio": "macstudio",
-};
+} as const satisfies Record;
export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = {
server: "Server",
@@ -30,7 +30,7 @@ export function EnvironmentMachineSymbol(props: {
return (
(null);
/* ─── Component ──────────────────────────────────────────────────────── */
-export function ProjectFavicon(props: {
+export const ProjectFavicon = memo(function ProjectFavicon(props: {
readonly environmentId: EnvironmentId;
readonly open?: boolean;
readonly size?: number;
@@ -50,15 +50,29 @@ export function ProjectFavicon(props: {
faviconPath: props.faviconPath,
}),
);
- const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
+ const renderableFaviconUrl = useMemo(
+ () => (isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl),
+ [faviconUrl],
+ );
// Inline images are self-contained; remote URLs key on their revision so signed-token
// rotation reuses the disk cache while a changed icon starts from the loading state.
- const cacheKey =
- renderableFaviconUrl && props.workspaceRoot
- ? renderableFaviconUrl.startsWith("data:")
- ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath)
- : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
- : null;
+ const cacheKey = useMemo(
+ () =>
+ renderableFaviconUrl && props.workspaceRoot
+ ? renderableFaviconUrl.startsWith("data:")
+ ? getProjectFaviconResourceKey(
+ props.environmentId,
+ props.workspaceRoot,
+ props.faviconPath,
+ )
+ : getProjectFaviconCacheKey(
+ props.environmentId,
+ props.workspaceRoot,
+ renderableFaviconUrl,
+ )
+ : null,
+ [renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath],
+ );
if (glyph !== null) {
return ;
@@ -74,7 +88,7 @@ export function ProjectFavicon(props: {
size={size}
/>
);
-}
+});
function ProjectIconGlyphView(props: { readonly glyph: ProjectIconGlyph; readonly size: number }) {
const { glyph, size } = props;
diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index 49de96a8d74c..964c60ebcbe2 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -2,14 +2,66 @@ import { Image } from "expo-image";
import { Path, Svg } from "react-native-svg";
import { View } from "react-native";
import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
+import { useState } from "react";
+import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./AppText";
type ProviderIconProps = {
readonly provider: string | null | undefined;
+ readonly iconUrl?: string | null | undefined;
readonly size?: number;
};
+function AcpRegistryFallbackIcon(props: { readonly color: string; readonly size: number }) {
+ return (
+
+ );
+}
+
+function AcpRegistryProviderIcon(props: {
+ readonly color: string;
+ readonly iconUrl: string | null | undefined;
+ readonly size: number;
+}) {
+ const iconUrl = resolveOfficialAcpRegistryIconUrl(props.iconUrl);
+ const [image, setImage] = useState<{
+ readonly iconUrl: string;
+ readonly status: "loaded" | "failed";
+ } | null>(null);
+ const currentImage = image?.iconUrl === iconUrl ? image : null;
+ const loaded = currentImage?.status === "loaded";
+
+ return (
+
+ {!loaded ? : null}
+ {iconUrl !== null && currentImage?.status !== "failed" ? (
+ setImage({ iconUrl, status: "failed" })}
+ onLoad={() => setImage({ iconUrl, status: "loaded" })}
+ />
+ ) : null}
+
+ );
+}
+
export function ProviderIcon(props: ProviderIconProps) {
const { themeAppearance } = useAppearancePreferences();
const isDarkMode = themeAppearance === "dark";
@@ -25,6 +77,9 @@ export function ProviderIcon(props: ProviderIconProps) {
/>
);
}
+ if (props.provider === "acpRegistry") {
+ return ;
+ }
if (props.provider === "claudeAgent") {
return (
@@ -64,6 +119,20 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}
+ if (props.provider === "pi") {
+ const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F";
+ return (
+
+ );
+ }
+
if (props.provider === "opencode") {
return (
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index d04ad7bc776c..d1470315a566 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -1,10 +1,9 @@
+import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
+import { useNavigation } from "@react-navigation/native";
import type { WorktreeSetupCardProps } from "./worktree-setup-card";
import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
-import {
- appendCodexArtifactTemplateUsePrompt,
- type CodexArtifactTemplate,
-} from "@t3tools/client-runtime/codex-artifact-templates";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type {
CodexFeedbackSubmission,
EnvironmentThreadStatus,
@@ -13,21 +12,34 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
import type { LegendListRef } from "@legendapp/list/react-native";
import { HeaderHeightContext } from "@react-navigation/elements";
-import { useNavigation } from "@react-navigation/native";
import type {
- ApprovalRequestId,
EnvironmentId,
MessageId,
ModelSelection,
- OrchestrationThreadShell,
ProviderApprovalDecision,
ProviderInteractionMode,
RuntimeMode,
+ RuntimeRequestId,
ServerConfig as T3ServerConfig,
ThreadId,
UsageLimitsReport,
- UserInputQuestion,
} from "@t3tools/contracts";
+import {
+ appendCodexArtifactTemplateUsePrompt,
+ type CodexArtifactTemplate,
+} from "@t3tools/client-runtime/codex-artifact-templates";
+import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
+import { presentPendingBackgroundWork } from "@t3tools/client-runtime/state/thread-execution";
+import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
+import {
+ formatModelSelectionEffort,
+ type ProviderSubagentStatus,
+} from "@t3tools/client-runtime/state/thread-execution";
+import { formatModelSlugName } from "@t3tools/shared/model";
+import { isProviderNativeSubagentThread } from "@t3tools/contracts";
+import type { QueuedRunEdit } from "../../state/queued-run-edit";
+import type { FollowUpBehavior } from "../../lib/followUpBehavior";
+import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
import * as Haptics from "expo-haptics";
import {
memo,
@@ -61,6 +73,7 @@ import Animated, {
ReduceMotion,
useAnimatedReaction,
useAnimatedStyle,
+ useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
@@ -73,24 +86,27 @@ import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerAttachment } from "../../lib/composerImages";
import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
-import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
import { deviceEnvironment } from "../../state/device";
import { useEnvironmentQuery } from "../../state/query";
import { threadDevicePreviews } from "../devices/threadDevicePreviews";
import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
import { scopedThreadKey } from "../../lib/scopedEntities";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
import { useDelayedStatus } from "../../lib/useDelayedStatus";
import type {
PendingApproval,
PendingUserInput,
PendingUserInputDraftAnswer,
ThreadFeedEntry,
+ ThreadFeedLatestRun,
} from "../../lib/threadActivity";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { ComposerFeedback } from "./ComposerFeedback";
import { ComposerUsageLimits } from "./ComposerUsageLimits";
import { PendingUserInputCard } from "./PendingUserInputCard";
+import { ProviderSubagentBar } from "./ProviderSubagentBar";
import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
import {
FLOATING_WORKING_CONTROL_COVERAGE,
@@ -109,14 +125,17 @@ import {
COMPOSER_TRANSITION_DURATION_MS,
ThreadComposer,
} from "./ThreadComposer";
-import { ThreadFeed } from "./ThreadFeed";
+import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
+import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
+import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
+import { useThreadQueuedCount } from "./ThreadQueueControl";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
export interface ThreadDetailScreenProps {
readonly worktreeSetup?: WorktreeSetupCardProps | null;
readonly setupWorkingStartedAt?: string | null;
- readonly selectedThread: OrchestrationThreadShell;
+ readonly selectedThread: EnvironmentThreadShell;
readonly contentPresentation: ThreadContentPresentation;
readonly screenTone: StatusTone;
readonly connectionError: string | null;
@@ -124,7 +143,12 @@ export interface ThreadDetailScreenProps {
readonly feedbackSubmissions: ReadonlyArray;
readonly onDismissFeedback: (id: MessageId) => void;
readonly selectedThreadFeed: ReadonlyArray;
+ readonly activityRun: ThreadFeedLatestRun | null;
readonly activeWorkStartedAt: string | null;
+ /** The live work is a provider-native subagent's runless root turn. */
+ readonly runlessWorkActive?: boolean;
+ /** Set on a provider-native subagent thread, which shows status instead of a composer. */
+ readonly providerSubagentStatus?: ProviderSubagentStatus | null;
readonly isCompacting: boolean;
/**
* The server has not created this thread yet. "preparing" runs while the
@@ -136,18 +160,28 @@ export interface ThreadDetailScreenProps {
| { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void }
| null;
readonly activePendingApproval: PendingApproval | null;
- readonly respondingApprovalId: ApprovalRequestId | null;
+ readonly respondingApprovalId: RuntimeRequestId | null;
readonly activePendingUserInput: PendingUserInput | null;
readonly activePendingUserInputDrafts: Record;
readonly activePendingUserInputAnswers: Record> | null;
- readonly respondingUserInputId: ApprovalRequestId | null;
+ readonly respondingUserInputId: RuntimeRequestId | null;
readonly draftMessage: string;
readonly draftAttachments: ReadonlyArray;
readonly connectionStateLabel: EnvironmentConnectionPhase;
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
- /** Non-null when older turns exist beyond the loaded window. */
- readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
+ /** Progressive history controls for oversized mobile thread opens. */
+ readonly historyControls?: ThreadFeedHistoryControls;
+ readonly activeThreadBusy: boolean;
+ readonly canStopThread: boolean;
+ /** Set while a queued message is open in the composer for editing. */
+ readonly queuedRunEdit: QueuedRunEdit | null;
+ readonly composerDraftKey: string | null;
+ readonly followUpBehavior: FollowUpBehavior;
+ readonly canSteerActiveTurn: boolean;
+ readonly isSavingQueuedEdit: boolean;
+ readonly onCancelQueuedRunEdit: () => void;
+ readonly onRemoveQueuedEditAttachment: (attachmentId: string) => void;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly threadCwd: string | null;
@@ -166,22 +200,24 @@ export interface ThreadDetailScreenProps {
readonly onNativePasteText: (paste: ComposerTextPaste) => Promise;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
- readonly onSendMessage: () => Promise;
+ readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise;
readonly onReconnectEnvironment: () => void;
+ /** Whether the model picker may offer providers other than this thread's. */
+ readonly canSwitchThreadProvider: boolean;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;
readonly onRespondToApproval: (
- requestId: ApprovalRequestId,
+ requestId: RuntimeRequestId,
decision: ProviderApprovalDecision,
) => Promise;
readonly onSelectUserInputOption: (
- requestId: ApprovalRequestId,
- question: UserInputQuestion,
+ requestId: RuntimeRequestId,
+ question: ThreadUserInputQuestion,
value: string,
) => void;
readonly onChangeUserInputCustomAnswer: (
- requestId: ApprovalRequestId,
+ requestId: RuntimeRequestId,
questionId: string,
customAnswer: string,
) => void;
@@ -316,10 +352,29 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}
}, []);
const windowHeight = useWindowDimensions().height;
- const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT;
+ const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44;
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
+ const queuedCount = useThreadQueuedCount({
+ environmentId: props.environmentId,
+ threadId: props.selectedThread.id,
+ });
+ const turnSubagents = useThreadTurnSubagents({
+ environmentId: props.environmentId,
+ threadId: props.selectedThread.id,
+ });
+ const agentsSegment = resolveSubagentPillSegment(turnSubagents);
const composerEditorRef = useRef(null);
+ // A provider-native subagent shows status instead of a composer.
+ const isProviderSubagent = isProviderNativeSubagentThread(props.selectedThread.source);
+ // Entering edit mode from the queue sheet should land in a ready composer,
+ // not require a second tap on a composer already holding the message.
+ const editingRunId = props.queuedRunEdit?.runId ?? null;
+ useEffect(() => {
+ if (editingRunId === null) return;
+ const frame = requestAnimationFrame(() => composerEditorRef.current?.focus());
+ return () => cancelAnimationFrame(frame);
+ }, [editingRunId]);
const draftMessageRef = useRef(props.draftMessage);
draftMessageRef.current = props.draftMessage;
const composerOverlayRef = useRef(null);
@@ -375,6 +430,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// One floating pill above the composer: it reads the connection phase while
// disconnected, the sync state while messages load, then the working timer
// once the feed is settled.
+ // The shell's roster is the server's post-settlement view of what still runs.
+ const pendingBackgroundWork = presentPendingBackgroundWork(
+ props.selectedThread.pendingBackgroundTasks,
+ );
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
connectionError: props.connectionError,
@@ -408,6 +467,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
return { kind: "working", startedAt: props.activeWorkStartedAt };
}
+ if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
+ return {
+ kind: "waiting",
+ label: pendingBackgroundWork.title,
+ accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items
+ .map((item) => item.label)
+ .join(", ")}`,
+ };
+ }
return null;
})();
const showWorkingControl = floatingStatus !== null;
@@ -415,6 +483,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// stable when reconnecting hands off to syncing and then to a running turn.
const showFloatingStatus =
showWorkingControl ||
+ queuedCount > 0 ||
+ agentsSegment !== null ||
devicePreviews.length > 0 ||
props.connectionStateLabel !== "connected" ||
props.queuedMessages.length > 0 ||
@@ -430,7 +500,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
((entry.message.attachments?.length ?? 0) > 0 ||
entry.message.text.trim().toLowerCase() !== "/compact"),
) ||
- (Boolean(props.loadEarlier) && props.selectedThread.latestUserMessageAt !== null);
+ (props.historyControls?.hasMoreHistory === true &&
+ props.selectedThread.latestUserMessageAt !== null);
const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
const composerOverlapHeight = composerChrome + composerBottomInset;
// While a user-input request is pending, the questionnaire owns the
@@ -440,7 +511,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// keyboard animations coherent. Collapse state is keyed by request id so a
// new request re-expands automatically.
const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =
- useState(null);
+ useState(null);
const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
// The open /usage-limits panel for this thread, model and turn. Only the open
// moment is stored: the rows read live provider data, so a redeemed reset
@@ -457,7 +528,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const usageLimitsKey = [
selectedThreadKey,
props.selectedThread.modelSelection.instanceId,
- props.selectedThread.latestTurn?.turnId ?? "",
+ props.selectedThread.latestRun?.runId ?? "",
props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "",
].join(":");
// Drop the snapshot as soon as the key changes so it cannot resurface stale.
@@ -585,6 +656,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
},
[userInputCoverageApplies],
);
+ // The floating control is anchored to the bar's top edge, so ride it up
+ // with the expanded card instead of drawing it over the questions.
+ const floatingControlLift = useDerivedValue(
+ () =>
+ userInputCoverageApplies ? userInputCardProgress.value * userInputCardCoverage.value : 0,
+ [userInputCoverageApplies],
+ );
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const endFollowEnabledRef = useRef(true);
endFollowEnabledRef.current = endFollowEnabled;
@@ -679,6 +757,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const layoutVariant = props.layoutVariant ?? "compact";
const isSplitLayout = layoutVariant === "split";
const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
+ const providerSubagentProvider = props.serverConfig?.providers.find(
+ (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId,
+ );
+ const providerSubagentCatalogModel = providerSubagentProvider?.models.find(
+ (model) => model.slug === props.selectedThread.modelSelection.model,
+ );
const workspaceContentWidth = useWorkspaceContentWidth();
// Clearing animated width can retain the unfolded width after Android resumes folded.
// Assign both layouts explicitly so the dock always follows its current parent.
@@ -704,6 +788,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
setComposerFocused(false);
}, [selectedThreadKey, showContent]);
+ const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
+ const lastDispatchedVisitRef = useRef(null);
+ const lastVisitDispatchRef = useRef({ threadKey: selectedThreadKey, at: 0 });
+ const selectedThreadId = props.selectedThread.id;
+ const selectedThreadUpdatedAt = props.selectedThread.updatedAt;
+ const selectedThreadLastVisitedAt = props.selectedThread.lastVisitedAt;
+ const selectedThreadCompletedAt = props.selectedThread.latestRun?.completedAt;
+ useEffect(() => {
+ // Records the server-side visited watermark while the thread is on
+ // screen (mirror of web ChatView), so the "Done" marker clears on every
+ // device. Field absent → the server predates visited tracking.
+ if (!showContent || selectedThreadLastVisitedAt === undefined) return;
+ const threadUpdatedAtMs = Date.parse(selectedThreadUpdatedAt);
+ if (Number.isNaN(threadUpdatedAtMs)) return;
+ const lastVisitedAtMs = selectedThreadLastVisitedAt
+ ? Date.parse(selectedThreadLastVisitedAt)
+ : NaN;
+ if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= threadUpdatedAtMs) return;
+ // Dedupe per watermark — the effect re-runs before the command echo lands.
+ const dispatchKey = `${selectedThreadKey}:${selectedThreadUpdatedAt}`;
+ if (lastDispatchedVisitRef.current === dispatchKey) return;
+ const dispatch = () => {
+ lastDispatchedVisitRef.current = dispatchKey;
+ lastVisitDispatchRef.current = { threadKey: selectedThreadKey, at: Date.now() };
+ void visitThread({
+ environmentId: props.environmentId,
+ input: { threadId: selectedThreadId, visitedAt: selectedThreadUpdatedAt },
+ });
+ };
+ // Completion clears unread state immediately; streaming watermarks use the
+ // same ten-second trailing throttle as web, keeping the newest update.
+ const completedAtMs = selectedThreadCompletedAt ? Date.parse(selectedThreadCompletedAt) : NaN;
+ const hasUnseenCompletion =
+ !Number.isNaN(completedAtMs) &&
+ (Number.isNaN(lastVisitedAtMs) || completedAtMs > lastVisitedAtMs);
+ const previous = lastVisitDispatchRef.current;
+ const elapsed = Date.now() - previous.at;
+ if (previous.threadKey !== selectedThreadKey || hasUnseenCompletion || elapsed >= 10_000) {
+ dispatch();
+ return;
+ }
+ const timer = setTimeout(dispatch, 10_000 - elapsed);
+ return () => clearTimeout(timer);
+ }, [
+ props.environmentId,
+ selectedThreadId,
+ selectedThreadKey,
+ selectedThreadLastVisitedAt,
+ selectedThreadCompletedAt,
+ selectedThreadUpdatedAt,
+ showContent,
+ visitThread,
+ ]);
+
useEffect(() => {
setAnchorMessageId(null);
setSubmittedMessageId(null);
@@ -770,40 +908,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
selectedThreadKey,
]);
- const handleSendMessage = useCallback(async () => {
- const targetThreadKey = selectedThreadKey;
- const hasUserMessage = selectedThreadFeed.some(
- (entry) => entry.type === "message" && entry.message.role === "user",
- );
- const messageId = await props.onSendMessage();
- if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
- return messageId;
- }
+ const handleSendMessage = useCallback(
+ async (followUp?: ActiveTurnComposerAction) => {
+ const targetThreadKey = selectedThreadKey;
+ const hasUserMessage = selectedThreadFeed.some(
+ (entry) => entry.type === "message" && entry.message.role === "user",
+ );
+ const messageId = await props.onSendMessage(followUp);
+ if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
+ return messageId;
+ }
- // A sent message makes the snapshot stale; a refused send leaves it in place.
- clearUsageLimitsFor(targetThreadKey);
+ // A sent message makes the snapshot stale; a refused send leaves it in place.
+ clearUsageLimitsFor(targetThreadKey);
- setSubmittedMessageId(messageId);
- setAnchorMessageId(
- resolveThreadFeedSubmissionAnchor({
- currentAnchorMessageId: anchorMessageId,
- submittedMessageId: messageId,
- hasStartedTurn: props.selectedThread.latestTurn !== null,
- hasUserMessage,
- queuedMessageCount: props.selectedThreadQueueCount,
- }),
- );
- composerEditorRef.current?.blur();
- return messageId;
- }, [
- anchorMessageId,
- clearUsageLimitsFor,
- props.onSendMessage,
- props.selectedThread.latestTurn,
- props.selectedThreadQueueCount,
- selectedThreadFeed,
- selectedThreadKey,
- ]);
+ setSubmittedMessageId(messageId);
+ setAnchorMessageId(
+ resolveThreadFeedSubmissionAnchor({
+ currentAnchorMessageId: anchorMessageId,
+ submittedMessageId: messageId,
+ hasStartedTurn: props.selectedThread.latestRun !== null,
+ hasUserMessage,
+ queuedMessageCount: props.selectedThreadQueueCount,
+ }),
+ );
+ composerEditorRef.current?.blur();
+ return messageId;
+ },
+ [
+ anchorMessageId,
+ clearUsageLimitsFor,
+ props.onSendMessage,
+ props.selectedThread.latestRun,
+ props.selectedThreadQueueCount,
+ selectedThreadFeed,
+ selectedThreadKey,
+ ],
+ );
const handleEditPendingMessage = useCallback(async (message: QueuedThreadMessage) => {
try {
@@ -920,11 +1061,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
setupWorkingStartedAt={props.setupWorkingStartedAt}
queuedMessages={props.queuedMessages}
dispatchingMessageId={props.dispatchingMessageId}
- onEditPendingMessage={handleEditPendingMessage}
+ // A native subagent has no composer to edit a pending message in;
+ // Cancel on the edit banner would discard it.
+ onEditPendingMessage={isProviderSubagent ? null : handleEditPendingMessage}
contentPresentation={props.contentPresentation}
agentLabel={agentLabel}
- latestTurn={props.selectedThread.latestTurn}
+ threadTitle={props.selectedThread.title}
+ latestRun={props.activityRun}
activeWorkStartedAt={props.activeWorkStartedAt}
+ runlessWorkActive={props.runlessWorkActive ?? false}
listRef={listRef}
freeze={freeze}
anchorMessageId={anchorMessageId}
@@ -936,13 +1081,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
(showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
}
contentMaxWidth={contentMaxWidth}
+ historyControls={props.historyControls}
layoutVariant={layoutVariant}
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
onEndFollowEnabledChange={setEndFollowEnabled}
skills={selectedProviderSkills}
onUseArtifactTemplate={handleUseArtifactTemplate}
- loadEarlier={props.loadEarlier ?? null}
/>
@@ -976,6 +1121,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
0
? { count: devicePreviews.length, onPress: openDevicePreview }
@@ -983,8 +1129,41 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}
showScrollToEnd={showScrollToEndButton}
onScrollToEnd={handleScrollToEnd}
+ agents={agentsSegment}
+ onOpenAgents={() => {
+ Keyboard.dismiss();
+ navigation.navigate("ThreadAgents", {
+ environmentId: props.environmentId,
+ threadId: props.selectedThread.id,
+ });
+ }}
+ queuedCount={queuedCount}
+ onOpenQueue={() => {
+ Keyboard.dismiss();
+ navigation.navigate("ThreadQueue", {
+ environmentId: props.environmentId,
+ threadId: props.selectedThread.id,
+ });
+ }}
/>
+ {props.queuedRunEdit !== null ? (
+
+
+
+ ) : null}
+
{props.feedbackSubmissions.map((submission) => (
-
+ {isProviderSubagent ? (
+
+
+ navigation.navigate("Thread", {
+ environmentId: String(props.environmentId),
+ threadId: String(props.selectedThread.lineage.parentThreadId),
+ })
+ }
+ />
+
+ ) : (
+ <>
+
+ >
+ )}
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 83b0bef9c022..7d591d59e8c5 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1,3 +1,5 @@
+import { ThreadContextDivider } from "./thread-context-divider";
+import { ThreadHandoffRow } from "./thread-handoff-row";
import {
WorktreeWorkingHeader,
WorktreeSetupCard,
@@ -6,15 +8,19 @@ import {
import * as Haptics from "expo-haptics";
import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
-import type {
- ChatAttachment,
- ChatFileAttachment,
- ChatImageAttachment,
- EnvironmentId,
- MessageId,
- OrchestrationMessageContext,
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
+import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
+import {
+ type OrchestrationMessageContext,
ThreadId,
- TurnId,
+ type ChatAttachment,
+ type ChatFileAttachment,
+ type ChatImageAttachment,
+ type EnvironmentId,
+ type MessageId,
+ type OrchestrationV2ProjectedTurnItem,
+ type RunId,
} from "@t3tools/contracts";
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
@@ -88,7 +94,7 @@ import { isPdfFile } from "../../lib/filePreview";
import { flattenThemeColor } from "../../lib/mobileTheme";
import { PresentationSource } from "../../components/NativePresentation";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import Animated, { FadeIn, type SharedValue } from "react-native-reanimated";
+import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { useFontFamily } from "../../lib/useFontFamily";
@@ -133,6 +139,7 @@ import {
deriveThreadWorkLogSizing,
type LayoutVariant,
} from "../../lib/layout";
+import { uuidv4 } from "../../lib/uuid";
import {
resolveMarkdownFontSizes,
resolveNativeMarkdownTypography,
@@ -148,11 +155,13 @@ import {
resolveMarkdownLinkPresentation,
} from "@t3tools/mobile-markdown-text/links";
import {
+ failedFeedRunIds,
deriveThreadFeedPresentation,
- deriveUnsettledTurnId,
+ threadFeedRunIsUnsettled,
isContextCompactionActivityGroup,
+ isContextHandoffActivityGroup,
type ThreadFeedEntry,
- type ThreadFeedLatestTurn,
+ type ThreadFeedLatestRun,
} from "../../lib/threadActivity";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import {
@@ -162,17 +171,16 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
- ThreadAgentSpawnCard,
ThreadDisclosureChevron,
- ThreadReasoningRow,
+ THREAD_DISCLOSURE_TRANSITION_MS,
ThreadWorkGroupToggle,
ThreadThinkingRow,
ThreadWorkLog,
- THREAD_DISCLOSURE_TRANSITION_MS,
WORK_GROUP_TOGGLE_HEIGHT,
} from "./thread-work-log";
import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed";
import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
+import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
import {
assetEnvironment,
@@ -185,12 +193,18 @@ import { usePreparedConnection } from "../../state/session";
import { useThreadSelection } from "../../state/use-thread-selection";
import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
import * as Option from "effect/Option";
+import { appAtomRegistry } from "../../state/atom-registry";
+import { environmentThreadShells, threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useV2ItemSupport } from "../../state/v2-item-support";
import {
basename,
fileRoutePathSegments,
isAbsolutePath,
resolveWorkspaceRelativeFilePath,
} from "../files/filePath";
+import { waitForThreadShellReady } from "./threadForkNavigation";
+import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
import { useFileChipShare } from "./useFileChipShare";
import {
@@ -200,9 +214,6 @@ import {
ThreadMarkdownImageView,
} from "./ThreadMarkdownImage";
-/** `ml-7` gutter plus the `px-3` padding of the expanded reasoning container. */
-const REASONING_CONTENT_INSET = 52;
-
const WIDE_MARKDOWN_BLOCK_OPTIONS = {
// Native iOS blockquotes and adjacent selectable text are separate layout
// chunks. Giving their shrink-to-fit bubble a definite width keeps both
@@ -244,20 +255,30 @@ function isFreshTimestamp(input: string): boolean {
return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;
}
+export interface ThreadFeedHistoryControls {
+ readonly hasMoreHistory: boolean;
+ readonly loading: boolean;
+ readonly error: string | null;
+ readonly onLoadEarlier: () => void;
+}
+
export interface ThreadFeedProps {
readonly worktreeSetup?: WorktreeSetupCardProps | null;
readonly setupWorkingStartedAt?: string | null;
readonly queuedMessages: ReadonlyArray;
readonly dispatchingMessageId: MessageId | null;
- readonly onEditPendingMessage: (message: QueuedThreadMessage) => void;
+ /** Null where a pending message cannot be edited (no composer to edit it in). */
+ readonly onEditPendingMessage: ((message: QueuedThreadMessage) => void) | null;
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
+ readonly threadTitle: string;
readonly workspaceRoot?: string | null;
readonly feed: ReadonlyArray;
readonly contentPresentation: ThreadContentPresentation;
readonly agentLabel: string;
- readonly latestTurn: ThreadFeedLatestTurn | null;
+ readonly latestRun: ThreadFeedLatestRun | null;
readonly activeWorkStartedAt: string | null;
+ readonly runlessWorkActive?: boolean;
readonly listRef: RefObject;
readonly freeze: SharedValue;
readonly anchorMessageId: MessageId | null;
@@ -265,6 +286,7 @@ export interface ThreadFeedProps {
readonly contentInsetEndAdjustment: SharedValue;
readonly contentTopInset?: number;
readonly contentBottomInset?: number;
+ readonly historyControls?: ThreadFeedHistoryControls;
readonly contentMaxWidth?: number;
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
@@ -272,11 +294,90 @@ export interface ThreadFeedProps {
readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
readonly skills?: ReadonlyArray;
readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
- /** Non-null when older turns exist beyond the loaded window. */
- readonly loadEarlier?: {
- readonly loading: boolean;
- readonly onLoadEarlier: () => void;
- } | null;
+}
+
+async function waitForThreadShell(
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+): Promise {
+ const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId));
+ return waitForThreadShellReady({
+ read: () => appAtomRegistry.get(atom) !== null,
+ });
+}
+
+function AssistantForkButton(props: {
+ readonly environmentId: EnvironmentId;
+ readonly iconColor: ColorValue;
+ readonly projectedItem: OrchestrationV2ProjectedTurnItem;
+ readonly sourceTitle: string;
+}) {
+ const support = useV2ItemSupport({
+ environmentId: props.environmentId,
+ sourceThreadId: props.projectedItem.sourceThreadId,
+ sourceItemId: props.projectedItem.sourceItemId,
+ });
+ const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response");
+ const navigation = useNavigation();
+ const [busy, setBusy] = useState(false);
+ const canFork = canForkProjectedAssistantItem({
+ projectedItem: props.projectedItem,
+ capabilities: support.providerSession?.capabilities,
+ });
+ const runId = props.projectedItem.item.runId;
+
+ if (!canFork || runId === null) return null;
+
+ return (
+ {
+ const targetThreadId = ThreadId.make(uuidv4());
+ setBusy(true);
+ void Haptics.selectionAsync();
+ void forkFromRun({
+ environmentId: props.environmentId,
+ input: {
+ sourceThreadId: props.projectedItem.sourceThreadId,
+ targetThreadId,
+ runId,
+ title: `${props.sourceTitle} fork`,
+ creationSource: "mobile",
+ },
+ })
+ .then(async (result) => {
+ if (result._tag !== "Success") return;
+ const targetThreadReady = await waitForThreadShell(props.environmentId, targetThreadId);
+ if (!targetThreadReady) {
+ Alert.alert(
+ "Fork created",
+ "Its thread data did not reach this client. Reconnect and try opening it from the thread list.",
+ );
+ return;
+ }
+ navigation.navigate("Thread", {
+ environmentId: props.environmentId,
+ threadId: targetThreadId,
+ });
+ })
+ .finally(() => setBusy(false));
+ }}
+ className="h-7 w-7 items-center justify-center disabled:opacity-40"
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ );
}
function MessageAttachmentImage(props: {
@@ -1342,14 +1443,44 @@ function useMarkdownStyles(
markdownUserInlineCodeText,
nativeMarkdownTypography,
onLinkPress,
- regularFontFamily,
renderImage,
+ regularFontFamily,
themeMode,
userBubbleForegroundMuted,
userBubbleSkillForeground,
]);
}
+function AgentMessageAttribution(props: {
+ readonly environmentId: EnvironmentId;
+ readonly senderThreadId?: ThreadId;
+}) {
+ const navigation = useNavigation();
+ const senderThreadId = props.senderThreadId;
+ const label = (
+
+ Sent by another agent
+
+ );
+ return senderThreadId ? (
+
+ navigation.navigate("Thread", {
+ environmentId: String(props.environmentId),
+ threadId: String(senderThreadId),
+ })
+ }
+ >
+ {label}
+
+ ) : (
+ label
+ );
+}
+
function renderFeedEntry(
info: { item: PendingThreadFeedEntry; index: number },
props: Pick<
@@ -1359,25 +1490,26 @@ function renderFeedEntry(
| "skills"
| "dispatchingMessageId"
| "onEditPendingMessage"
+ | "threadId"
+ | "workspaceRoot"
> & {
readonly copiedRowId: string | null;
readonly expandedWorkRows: Record;
- readonly expandedReasoningMessageIds: ReadonlySet;
readonly workRowSizing: ReturnType;
readonly workGroupScrollPositions: Map;
readonly terminalAssistantMessageIds: ReadonlySet;
- readonly unsettledTurnId: TurnId | null;
- readonly isWorking: boolean;
+ readonly unsettledTurnId: RunId | null;
+ readonly failedRunIds: ReadonlySet;
readonly onCopyWorkRow: (rowId: string, value: string) => void;
- readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
- readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void;
- readonly onToggleTurnFold: (turnId: TurnId) => void;
- readonly onToggleReasoning: (messageId: string) => void;
+ readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void;
+ readonly onToggleWorkRow: (rowId: string, anchorKey?: string) => void;
+ readonly onToggleTurnFold: (runId: RunId) => void;
readonly onPressPreview: (source: FilePreviewSource) => void;
readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
readonly markdownLinkHandlers: MarkdownLinkHandlers;
readonly renderMarkdownImage: MarkdownImageRenderer;
readonly renderViewedImage: MarkdownImageRenderer;
+ readonly renderReasoning: (text: string) => ReactNode;
readonly iconSubtleColor: string | import("react-native").ColorValue;
readonly screenColor: string;
readonly userBubbleColor: string | import("react-native").ColorValue;
@@ -1388,17 +1520,18 @@ function renderFeedEntry(
readonly userBubbleMaxWidth: number;
/** Width assistant markdown lays out in, so images can size their frame before layout. */
readonly markdownContentWidth: number;
+ readonly threadTitle: string;
},
) {
const entry = info.item;
const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
- if (entry.type === "turn-fold") {
+ if (entry.type === "run-fold") {
return (
props.onToggleTurnFold(entry.turnId)}
+ onPress={() => props.onToggleTurnFold(entry.runId)}
hitSlop={4}
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border-subtle px-2"
style={{
@@ -1425,19 +1558,6 @@ function renderFeedEntry(
return ;
}
- if (entry.type === "agent-spawn") {
- return (
- props.onToggleWorkGroup(entry.id, entry.id)}
- onCopy={() => props.onCopyWorkRow(entry.activity.id, entry.activity.getCopyText())}
- />
- );
- }
-
if (entry.type === "work-toggle") {
return (
+ );
+ }
+
if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
const label = entry.activities[0]!.summary;
+ const active =
+ props.unsettledTurnId !== null &&
+ entry.runId === props.unsettledTurnId &&
+ entry.activities[0]!.projectedItem.item.status === "running";
return (
-
-
-
-
- {label}
-
-
-
+
);
}
if (entry.type === "message") {
const { message } = entry;
- if (message.role === "reasoning") {
- const messages = entry.reasoningMessages ?? [message];
- return (
- 1 ? ` (×${messages.length})` : ""}`}
- streaming={false}
- onToggle={() => props.onToggleReasoning(entry.id)}
- >
-
-
- {messages.map((reasoningMessage) => (
-
- ))}
-
-
-
- );
- }
const isUser = message.role === "user";
- const renderedText = renderAssistantCitationsAsText(message.text);
+ const presentation = resolveUserMessagePresentation(message);
+ const renderedText = renderAssistantCitationsAsText(presentation.text);
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
const attachments = message.attachments ?? [];
- const hasReviewCommentContext = message.text.includes(" reference.contextId),
);
@@ -1551,7 +1647,20 @@ function renderFeedEntry(
(attachment) => isImageAttachment(attachment) || !inlineAttachmentIds.has(attachment.id),
);
return (
-
+
+ {presentation.isAutomation ? (
+
+ Sent by automation
+
+ ) : message.createdBy === "agent" ? (
+
+ ) : null}
+ {intentBadge ? (
+
+
+ {intentBadge.label}
+
+
+ ) : null}
{entry.pendingMessage && !entry.acknowledged ? "Pending" : timestampLabel}
- {entry.pendingMessage &&
+ {props.onEditPendingMessage !== null &&
+ entry.pendingMessage &&
!entry.acknowledged &&
!entry.pendingMessage.creation &&
entry.pendingMessage.messageId !== props.dispatchingMessageId ? (
@@ -1649,16 +1783,18 @@ function renderFeedEntry(
hitSlop={8}
className="size-7 items-center justify-center"
onPress={() => {
- if (entry.pendingMessage) props.onEditPendingMessage(entry.pendingMessage);
+ if (entry.pendingMessage && props.onEditPendingMessage) {
+ props.onEditPendingMessage(entry.pendingMessage);
+ }
}}
>
) : null}
- {message.text.trim().length > 0 ? (
+ {presentation.text.trim().length > 0 ? (
@@ -1675,7 +1811,7 @@ function renderFeedEntry(
/>
) : null}
-
+
);
}
@@ -1692,7 +1828,12 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
{renderedText.trim().length > 0 ? (
@@ -1732,6 +1873,14 @@ function renderFeedEntry(
})}
{showAssistantMeta ? (
+ {message.projectedItem ? (
+
+ ) : null}
);
}
@@ -1799,6 +1950,13 @@ function UserMessageContent(props: UserMessageContentProps) {
props.linkHandlers.onLinkPress?.(record.path);
return;
}
+ if (record?.kind === "thread" && "threadId" in record) {
+ navigation.navigate("Thread", {
+ environmentId: String(record.environmentId),
+ threadId: String(record.threadId),
+ });
+ return;
+ }
// Documents open in the file screen; pictures, video and PDF keep their native viewers.
const document = composerDocumentAttachmentRecord(record);
if (document) {
@@ -1954,9 +2112,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef(null);
const disclosureSettleSecondFrameRef = useRef(null);
const disclosureAnchorKeyRef = useRef(null);
- const headerMaterialVisibleRef = useRef(false);
- const previousLatestTurnRef = useRef(props.latestTurn);
+ const previousLatestTurnRef = useRef(props.latestRun);
const userScrollSettleTimerRef = useRef | null>(null);
+ const headerMaterialVisibleRef = useRef(false);
const { width: windowWidth, fontScale } = useWindowDimensions();
const { appearance } = useAppearancePreferences();
const workRowSizing = useMemo(
@@ -1978,11 +2136,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const [viewportHeight, setViewportHeight] = useState(0);
const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
- // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
- // whenever the viewport drifts back inside its geometric threshold, which
- // yanked users off history they were reading every time a stream chunk grew
- // a row. Scrolling away or expanding a disclosure above the end breaks
- // follow; reaching the end (or sending / switching threads) re-arms it.
+ // Live-follow latch (#5566). LegendList's maintainScrollAtEnd alone re-pins
+ // the feed whenever the viewport drifts back inside its geometric threshold,
+ // which yanked users off history they were reading every time a stream chunk
+ // grew a row. Follow breaks when the user scrolls up and away, and re-arms
+ // only when the list actually returns to the end (or on send / thread switch).
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
@@ -2010,22 +2168,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
readonly copiedRowId: string | null;
readonly expandedWorkGroups: Record;
readonly expandedWorkRows: Record;
- readonly expandedTurnIds: ReadonlySet;
- readonly expandedReasoningMessageIds: ReadonlySet;
+ readonly expandedTurnIds: ReadonlySet;
}>({
copiedRowId: null,
expandedWorkGroups: {},
expandedWorkRows: {},
expandedTurnIds: new Set(),
- expandedReasoningMessageIds: new Set(),
});
- const {
- copiedRowId,
- expandedWorkGroups,
- expandedWorkRows,
- expandedTurnIds,
- expandedReasoningMessageIds,
- } = interactionState;
+ const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
const [expandedFile, setExpandedFile] = useState(null);
const [expandedVideo, setExpandedVideo] = useState(null);
const fileShareSourceIdentifier = useId();
@@ -2276,10 +2426,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[props.environmentId, props.threadId, props.workspaceRoot],
);
const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);
+ const renderReasoning = useCallback(
+ (text: string) => (
+
+ ),
+ [markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills],
+ );
const reviewCommentColors = useReviewCommentColors();
- // One definition of "still live", shared with the fold derivation: two
- // copies of this test are what let a row and the fold beside it disagree.
- const unsettledTurnId = deriveUnsettledTurnId(props.latestTurn ?? null);
+ const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;
// LegendList does not invalidate visible rows when only the renderItem closure changes.
// Include turn completion so unchanged message rows reveal their footer and spacing
// even when the final message update arrives before the turn settles.
@@ -2290,8 +2450,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
dispatchingMessageId: props.dispatchingMessageId,
unsettledTurnId,
copiedRowId,
+ expandedWorkGroups,
expandedWorkRows,
- expandedReasoningMessageIds,
workRowSizing,
iconSubtleColor,
markdownStyles,
@@ -2306,8 +2466,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.dispatchingMessageId,
unsettledTurnId,
copiedRowId,
+ expandedWorkGroups,
expandedWorkRows,
- expandedReasoningMessageIds,
workRowSizing,
iconSubtleColor,
markdownStyles,
@@ -2401,13 +2561,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]);
- const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
- const nextWidth = Math.round(event.nativeEvent.layout.width);
- const nextHeight = Math.round(event.nativeEvent.layout.height);
- setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
- setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
- }, []);
-
// Thread identity is env-scoped: two environments can hold the same
// ThreadId, and keying resets (or the list mount) on the bare id would
// carry stale scroll/follow state across an environment switch.
@@ -2418,11 +2571,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
() => new Map(),
[feedThreadKey],
);
-
- useEffect(() => {
- reportHeaderMaterialVisibility(false);
- }, [feedThreadKey, reportHeaderMaterialVisibility]);
-
// A thread switch opens pinned to the end; a send explicitly returns to the
// live edge (ThreadDetailScreen scrolls the new message into place). Both
// re-arm follow regardless of where the user had scrolled before.
@@ -2439,24 +2587,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
}, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]);
- const expandedWorkGroupIds = useMemo(() => {
- const ids = new Set();
- for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
- if (expanded) {
- ids.add(groupId);
- }
- }
- return ids;
- }, [expandedWorkGroups]);
+ const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
+ const nextWidth = Math.round(event.nativeEvent.layout.width);
+ const nextHeight = Math.round(event.nativeEvent.layout.height);
+ setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
+ setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
+ }, []);
+
+ useEffect(() => {
+ reportHeaderMaterialVisibility(false);
+ }, [feedThreadKey, reportHeaderMaterialVisibility]);
+
const presentedFeed = useMemo(
() =>
appendPendingThreadMessages(
deriveThreadFeedPresentation(
props.feed,
- props.latestTurn,
+ props.latestRun,
expandedTurnIds,
- expandedWorkGroupIds,
+ new Set(
+ Object.entries(expandedWorkGroups)
+ .filter(([, expanded]) => expanded)
+ .map(([groupId]) => groupId),
+ ),
props.activeWorkStartedAt,
+ props.runlessWorkActive ?? false,
),
props.feed,
props.queuedMessages,
@@ -2464,10 +2619,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[
props.queuedMessages,
expandedTurnIds,
- expandedWorkGroupIds,
+ expandedWorkGroups,
props.activeWorkStartedAt,
+ props.runlessWorkActive,
props.feed,
- props.latestTurn,
+ props.latestRun,
],
);
const setupAnchorIndex = presentedFeed.findIndex(
@@ -2493,29 +2649,33 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
resolveChatListAnchoredEndSpace(
presentedFeed,
props.anchorMessageId,
- (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null),
+ (entry) => (entry.type === "message" ? entry.id : null),
{ anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
),
[presentedFeed, props.anchorMessageId, anchorTopInset],
);
+ const failedRunIds = useMemo(
+ () => failedFeedRunIds(props.feed, props.latestRun),
+ [props.feed, props.latestRun],
+ );
const terminalAssistantMessageIds = useMemo(() => {
- const terminalIdsByTurn = new Map();
+ const terminalIdsByTurn = new Map();
for (const entry of props.feed) {
- if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {
- terminalIdsByTurn.set(entry.message.turnId, entry.message.id);
+ if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
+ terminalIdsByTurn.set(entry.message.runId, entry.message.id);
}
}
return new Set(terminalIdsByTurn.values());
}, [props.feed]);
useEffect(() => {
const previous = previousLatestTurnRef.current;
- previousLatestTurnRef.current = props.latestTurn;
- if (!props.latestTurn || !previous) {
+ previousLatestTurnRef.current = props.latestRun;
+ if (!props.latestRun || !previous) {
return;
}
- if (props.latestTurn.turnId === previous.turnId) {
- if (previous.state === "running" && props.latestTurn.state === "interrupted") {
- const interruptedTurnId = props.latestTurn.turnId;
+ if (props.latestRun.runId === previous.runId) {
+ if (previous.status === "running" && props.latestRun.status === "interrupted") {
+ const interruptedTurnId = props.latestRun.runId;
setInteractionState((current) => ({
...current,
expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId),
@@ -2524,14 +2684,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
return;
}
setInteractionState((current) => {
- if (!current.expandedTurnIds.has(previous.turnId)) {
+ if (!current.expandedTurnIds.has(previous.runId)) {
return current;
}
const next = new Set(current.expandedTurnIds);
- next.delete(previous.turnId);
+ next.delete(previous.runId);
return { ...current, expandedTurnIds: next };
});
- }, [props.latestTurn]);
+ }, [props.latestRun]);
useEffect(() => {
return () => {
@@ -2586,13 +2746,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
if (disclosureAnchorKeyRef.current !== null) {
settleDisclosureAfterLayout();
}
- }, [
- expandedTurnIds,
- expandedWorkGroups,
- expandedWorkRows,
- expandedReasoningMessageIds,
- settleDisclosureAfterLayout,
- ]);
+ }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
const handleItemSizeChanged = useCallback(() => {
if (disclosureAnchorKeyRef.current !== null) {
@@ -2632,8 +2786,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}, []);
const onToggleWorkGroup = useCallback(
- (groupId: string, anchorKey: string) => {
- suspendEndScrollMaintenanceForDisclosure(anchorKey);
+ (groupId: string, anchorKey?: string) => {
+ suspendEndScrollMaintenanceForDisclosure(anchorKey ?? `work-toggle:${groupId}`);
setInteractionState((current) => ({
...current,
expandedWorkGroups: {
@@ -2646,8 +2800,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const onToggleWorkRow = useCallback(
- (rowId: string, anchorKey: string) => {
- suspendEndScrollMaintenanceForDisclosure(anchorKey);
+ (rowId: string, anchorKey?: string) => {
+ suspendEndScrollMaintenanceForDisclosure(anchorKey ?? null);
setInteractionState((current) => ({
...current,
expandedWorkRows: {
@@ -2660,14 +2814,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const onToggleTurnFold = useCallback(
- (turnId: TurnId) => {
- suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
+ (runId: RunId) => {
+ suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`);
setInteractionState((current) => {
const next = new Set(current.expandedTurnIds);
- if (next.has(turnId)) {
- next.delete(turnId);
+ if (next.has(runId)) {
+ next.delete(runId);
} else {
- next.add(turnId);
+ next.add(runId);
}
return { ...current, expandedTurnIds: next };
});
@@ -2675,23 +2829,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[suspendEndScrollMaintenanceForDisclosure],
);
- const onToggleReasoning = useCallback(
- (messageId: string) => {
- // Reasoning details use their own row within the expanded activity history.
- suspendEndScrollMaintenanceForDisclosure(messageId);
- setInteractionState((current) => {
- const next = new Set(current.expandedReasoningMessageIds);
- if (next.has(messageId)) {
- next.delete(messageId);
- } else {
- next.add(messageId);
- }
- return { ...current, expandedReasoningMessageIds: next };
- });
- },
- [suspendEndScrollMaintenanceForDisclosure],
- );
-
const onPressPreview = useCallback((source: FilePreviewSource) => {
setExpandedFile((current) => current ?? source);
}, []);
@@ -2711,37 +2848,45 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// scrolling up through unmeasured content corrects each row's height as it
// mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
// exact; message rows stay undefined and use LegendList's per-type running
- // average once one of their type has been measured.
+ // average once one of their type has been measured. Prominent v2 items,
+ // expanded details, and compaction rows retain native measurement; their
+ // cards and related-thread links can exceed the compact row height.
const getFixedItemSize = useCallback(
(entry: ThreadFeedEntry) => {
if (workRowSizing.fixedRowHeight === undefined) {
return undefined;
}
switch (entry.type) {
- case "message":
- // A collapsed reasoning row is the same chrome as a work toggle.
- return entry.message.role === "reasoning" && !expandedReasoningMessageIds.has(entry.id)
- ? WORK_GROUP_TOGGLE_HEIGHT
- : undefined;
- case "turn-fold":
- return TURN_FOLD_HEIGHT;
+ case "run-fold":
+ return resolveThreadFeedFixedItemSize(entry.type);
case "work-toggle":
case "thinking":
return WORK_GROUP_TOGGLE_HEIGHT;
case "activity-group":
- if (isContextCompactionActivityGroup(entry)) {
+ if (entry.activities[0]?.projectedItem.item.type === "subagent") {
+ return undefined;
+ }
+ if (isContextCompactionActivityGroup(entry) || isContextHandoffActivityGroup(entry)) {
+ return undefined;
+ }
+ if (
+ entry.activities[0]?.groupedToolDetail &&
+ entry.activities.every((activity) => activity.workEntry.itemType === "reasoning")
+ ) {
return undefined;
}
// Expanded rows append a variable detail block — fall back to
// measurement for those groups.
- return entry.activities.some((activity) => expandedWorkRows[activity.id])
+ return entry.activities.some(
+ (activity) => activity.prominent || expandedWorkRows[activity.id],
+ )
? undefined
- : collapsedWorkLogHeight(entry.activities);
+ : collapsedWorkLogHeight(entry.activities, entry.continuesWorkLog);
default:
return undefined;
}
},
- [expandedReasoningMessageIds, expandedWorkRows, workRowSizing.fixedRowHeight],
+ [expandedWorkRows, workRowSizing.fixedRowHeight],
);
// Disclosures can mount existing offscreen rows as well as new work rows.
@@ -2757,24 +2902,25 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
environmentId: props.environmentId,
dispatchingMessageId: props.dispatchingMessageId,
onEditPendingMessage: props.onEditPendingMessage,
+ onUseArtifactTemplate: props.onUseArtifactTemplate,
+ threadId: props.threadId,
copiedRowId,
expandedWorkRows,
- expandedReasoningMessageIds,
workRowSizing,
workGroupScrollPositions,
terminalAssistantMessageIds,
unsettledTurnId,
- isWorking: props.activeWorkStartedAt !== null,
+ failedRunIds,
onCopyWorkRow,
onToggleWorkGroup,
onToggleWorkRow,
onToggleTurnFold,
- onToggleReasoning,
onPressPreview,
onPressVideo,
markdownLinkHandlers,
renderMarkdownImage,
renderViewedImage,
+ renderReasoning,
iconSubtleColor,
screenColor,
userBubbleColor,
@@ -2784,8 +2930,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
themeAppearance,
userBubbleMaxWidth,
markdownContentWidth,
+ threadTitle: props.threadTitle,
skills: props.skills,
- onUseArtifactTemplate: props.onUseArtifactTemplate,
+ workspaceRoot: props.workspaceRoot,
})}
{props.worktreeSetup && info.index === setupAnchorIndex ? (
@@ -2805,12 +2952,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
copiedRowId,
disclosureToggleSettling,
expandedWorkRows,
- expandedReasoningMessageIds,
workRowSizing,
workGroupScrollPositions,
terminalAssistantMessageIds,
unsettledTurnId,
- props.activeWorkStartedAt,
+ failedRunIds,
iconSubtleColor,
screenColor,
userBubbleColor,
@@ -2824,15 +2970,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
markdownLinkHandlers,
onPressPreview,
onPressVideo,
- onToggleReasoning,
onToggleTurnFold,
onToggleWorkGroup,
onToggleWorkRow,
props.environmentId,
props.onUseArtifactTemplate,
+ props.threadId,
+ props.threadTitle,
props.skills,
+ props.workspaceRoot,
renderMarkdownImage,
renderViewedImage,
+ renderReasoning,
],
);
@@ -2881,7 +3030,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
: { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
- // Patched LegendList prop (patches/@legendapp__list@3.3.5.patch):
+ // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
// lets its scroll math clamp programmatic scrolls to -headerInset
// instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
// content rest below the transparent header rather than at frame top.
@@ -2893,16 +3042,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// ThreadDetailScreen); this tells LegendList's scroll math about the
// extra so programmatic end scrolls land at the true resting offset.
contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}
- // Android: the composer overlay only exists as the keyboard
- // integration's animated bottom padding, which the list's scroll
- // math cannot see until the inset reports above land — and those
- // arrive via runOnJS, racing the remounted list's one-shot initial
- // scroll-at-end. Seed the estimated overlay height as a declarative
- // contentInset floor: LegendList consumes it in JS math only
- // (Android's ScrollView has no native contentInset prop) and the
- // first reported override REPLACES it instead of adding to it.
- // Not on iOS: there the prop would reach UIKit and inset natively
- // on top of the animated padding.
+ // Android's initial end scroll can run before the keyboard integration
+ // reports the composer height. Seed that estimate for LegendList's
+ // scroll math until the first reported inset replaces it.
{...(initialContentInset ? { contentInset: initialContentInset } : {})}
// The keyboard integration's offset math (end pinning, max scroll)
// must add the same UIKit-added extra, or its keyboard-open end
@@ -2978,16 +3120,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
{setupAnchorIndex < 0 && props.worktreeSetup ? (
) : null}
- {props.loadEarlier != null ? (
-
-
- {props.loadEarlier.loading ? "Loading earlier turns…" : "Load earlier turns"}
-
-
+ {props.historyControls ? (
+
) : null}
>
}
@@ -3017,3 +3151,39 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
});
+
+function ThreadFeedLoadEarlierControl(props: ThreadFeedHistoryControls) {
+ const theme = useUniwindTheme();
+ const mutedColor = theme["--color-icon-subtle"];
+ const accentColor = theme["--color-primary"];
+ if (!props.hasMoreHistory && props.error === null) {
+ return null;
+ }
+ return (
+
+ {props.hasMoreHistory ? (
+
+ {props.loading ? (
+
+ ) : (
+
+ )}
+
+ {props.loading ? "Loading earlier activity…" : "Load earlier activity"}
+
+
+ ) : null}
+ {props.error !== null ? (
+
+ {props.error}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index e3a4033cd809..18471f88af09 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -88,6 +88,8 @@ export type ThreadGitMenuProps = {
readonly gitOperationLabel: string | null;
readonly onOpenFilesInspector?: () => void;
readonly onOpenGitInspector?: () => void;
+ /** Present only on a thread whose work can be merged into the one it came from. */
+ readonly onMergeBack?: () => void;
readonly onPull: () => Promise;
readonly onRunAction: (input: GitActionRequestInput) => Promise;
};
@@ -354,6 +356,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
onPress: model.openReview,
type: "action",
},
+ ...(props.onMergeBack
+ ? [
+ {
+ description: "Bring this thread's latest turn into its source",
+ icon: { name: "arrow.triangle.merge", type: "sfSymbol" as const },
+ label: "Merge back to source",
+ onPress: props.onMergeBack,
+ type: "action" as const,
+ },
+ ]
+ : []),
{
description: "Commit, files, branches",
icon: { name: "ellipsis", type: "sfSymbol" },
@@ -383,6 +396,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
props.canOpenFiles,
props.canOpenTerminal,
props.gitStatus,
+ props.onMergeBack,
props.onOpenNewTerminal,
props.onOpenTerminal,
props.onRunProjectScript,
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 63f7b48a0bb0..d60425a6af72 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -12,7 +12,6 @@ import {
import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
-import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent, TextInputInstance } from "react-native";
import { Platform, StyleSheet, TextInput, View } from "react-native";
@@ -28,11 +27,11 @@ import { SymbolView } from "../../components/AppSymbol";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
-import { useProjects, useThreadShells } from "../../state/entities";
+import { useProjects, useNavigationThreadShells } from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
import { usePendingThreadOrder } from "../../state/thread-order";
-import { environmentServerConfigsAtom } from "../../state/server";
+import { threadListEnvironmentsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import { useWorkspaceState } from "../../state/workspace";
@@ -136,7 +135,7 @@ function ThreadNavigationSidebarPane(
const insets = useSafeAreaInsets();
const { fabClearance } = useAndroidControlSizing();
const projects = useProjects();
- const threads = useThreadShells();
+ const threads = useNavigationThreadShells();
const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
const { savedConnectionsById } = useSavedRemoteConnections();
const searchInputRef = useRef(null);
@@ -309,83 +308,19 @@ function ThreadNavigationSidebarPane(
}, []);
// Threads on servers without the settlement capability never classify as
// settled (the user could neither un-settle nor pin them).
- const serverConfigs = useAtomValue(environmentServerConfigsAtom);
- const settlementEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadSettlement === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const snoozeEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadSnooze === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const pinningEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadPinning === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const autoSettleOptOutEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadAutoSettleOptOut === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const pinReorderEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadPinReorder === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const activeReorderEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadActiveReorder === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const titleRegenerationEnvironmentIds = useMemo(() => {
- const supported = new Set();
- for (const [environmentId, config] of serverConfigs) {
- if (config.environment.capabilities.threadTitleRegeneration === true) {
- supported.add(environmentId);
- }
- }
- return supported;
- }, [serverConfigs]);
- const machineByEnvironmentId = useMemo(
- () =>
- new Map(
- [...serverConfigs].map(
- ([environmentId, config]) =>
- [environmentId, resolveEnvironmentMachineKind(config)] as const,
- ),
- ),
- [serverConfigs],
- );
- // Reference-stable provider glyphs: a fresh object per render would break
- // the memoized rows' props comparison on every parent render.
- const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs);
+ const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
+ const {
+ providersByEnvironmentId,
+ machineByEnvironmentId,
+ settlementEnvironmentIds,
+ snoozeEnvironmentIds,
+ pinningEnvironmentIds,
+ autoSettleOptOutEnvironmentIds,
+ pinReorderEnvironmentIds,
+ activeReorderEnvironmentIds,
+ titleRegenerationEnvironmentIds,
+ } = listEnvironments;
+ const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
// Up/down menu availability for every card, computed once per section per
// rebuild (see computeThreadMoveAvailability): per-thread planner calls made
@@ -396,15 +331,8 @@ function ThreadNavigationSidebarPane(
allThreads: threads,
section,
pendingOrder,
- reorderableEnvironmentIds: new Set(
- [...serverConfigs].flatMap(([id, config]) =>
- (section === "pinned"
- ? config.environment.capabilities.threadPinReorder
- : config.environment.capabilities.threadActiveReorder) === true
- ? [id]
- : [],
- ),
- ),
+ reorderableEnvironmentIds:
+ section === "pinned" ? pinReorderEnvironmentIds : activeReorderEnvironmentIds,
ordered: getThreadListV2OrderedSection({
threads,
section,
@@ -417,7 +345,8 @@ function ThreadNavigationSidebarPane(
});
return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);
}, [
- serverConfigs,
+ pinReorderEnvironmentIds,
+ activeReorderEnvironmentIds,
threads,
pendingOrder,
queuedThreadKeys,
@@ -656,7 +585,7 @@ function ThreadNavigationSidebarPane(
projectByKey,
projectTitleByProjectKey,
savedConnectionsById,
- serverConfigs,
+ listEnvironments,
threadSearchMatchByKey,
}),
[
@@ -664,7 +593,7 @@ function ThreadNavigationSidebarPane(
projectByKey,
projectTitleByProjectKey,
savedConnectionsById,
- serverConfigs,
+ listEnvironments,
threadSearchMatchByKey,
],
);
@@ -750,6 +679,7 @@ function ThreadNavigationSidebarPane(
project={projectByKey.get(scopeKey) ?? null}
projectTitle={projectTitleByProjectKey.get(scopeKey)}
providerInstance={resolveProviderInstance(thread)}
+ providers={providersByEnvironmentId.get(thread.environmentId)}
environmentLabel={
Object.keys(savedConnectionsById).length > 1
? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
@@ -844,18 +774,20 @@ function ThreadNavigationSidebarPane(
pinThread,
pinningEnvironmentIds,
autoSettleOptOutEnvironmentIds,
+ autoSettleOptOutEnvironmentIds,
setThreadAutoSettle,
projectByKey,
projectTitleByProjectKey,
regenerateThreadTitle,
renameThread,
- threadSearchMatchByKey,
- props.onNewThreadInProject,
props.onNewThreadOnBranch,
props.searchQuery,
props.selectedThreadKey,
props.width,
savedConnectionsById,
+ resolveProviderInstance,
+ providersByEnvironmentId,
+ threadSearchMatchByKey,
titleRegenerationEnvironmentIds,
settleThread,
settlementEnvironmentIds,
@@ -863,7 +795,6 @@ function ThreadNavigationSidebarPane(
sidebarScrollGesture,
snoozeEnvironmentIds,
snoozeThread,
- resolveProviderInstance,
toggleSettledShelf,
toggleSnoozedShelf,
unpinThread,
diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx
new file mode 100644
index 000000000000..adc1131a05d1
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx
@@ -0,0 +1,624 @@
+import { type StaticScreenProps, useNavigation } from "@react-navigation/native";
+import { useAtomValue } from "@effect/atom-react";
+import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
+import { Image } from "expo-image";
+import * as Haptics from "expo-haptics";
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { Animated, Platform, Pressable, ScrollView, View } from "react-native";
+import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
+import ReanimatedSwipeable, {
+ type SwipeableMethods,
+} from "react-native-gesture-handler/ReanimatedSwipeable";
+import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
+
+import { MaterialButton } from "../../components/MaterialButton";
+import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { ControlPillMenu } from "../../components/ControlPill";
+import { scopedThreadKey } from "../../lib/scopedEntities";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
+import { useAssetUrl } from "../../state/assets";
+import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit";
+import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import {
+ buildCancelQueuedRunCommand,
+ resolveQueueDragBeforeRunId,
+ resolveQueueDropBeforeRunId,
+ resolveThreadQueueRowControls,
+} from "./threadQueueControlPresentation";
+import { threadDragGapOffset } from "./threadDragGap";
+
+const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
+const REMOVE_ACTION_WIDTH = 76;
+const THUMBNAIL_LIMIT = 3;
+
+type QueueTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
+type QueueAction = "steer" | "edit" | "up" | "down" | "remove";
+type QueueRowLayout = { readonly id: RunId; readonly y?: number; readonly height?: number };
+
+export function useThreadQueueWorkflow(target: QueueTarget) {
+ return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target));
+}
+
+export function useThreadQueuedCount(target: QueueTarget) {
+ return useAtomValue(environmentThreadDetails.queuedCountAtom(target));
+}
+
+export function ThreadQueueSheet({ route }: StaticScreenProps) {
+ const target = route.params;
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const theme = useUniwindTheme();
+ const workflow = useThreadQueueWorkflow(target);
+ const threadKey = scopedThreadKey(target.environmentId, target.threadId);
+ const editing = useQueuedRunEdit(threadKey);
+ const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun, "reorder queued message");
+ const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message");
+ const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun, "remove queued message");
+ const resume = useAtomCommand(threadEnvironment.resumeThreadQueue, "resume queue");
+ const [resuming, setResuming] = useState(false);
+ const [busyRunId, setBusyRunId] = useState(null);
+ const busyRef = useRef(false);
+ const [draggedRunId, setDraggedRunId] = useState(null);
+ const [previewBeforeRunId, setPreviewBeforeRunId] = useState();
+ const [dragRows, setDragRows] = useState | null>(null);
+ const rowLayouts = useRef(new Map());
+ const drag = useRef<{
+ runId: RunId;
+ order: string;
+ beforeRunId: RunId | null | undefined;
+ rows: ReadonlyArray;
+ } | null>(null);
+ const [translation] = useState(() => new Animated.Value(0));
+ const queuedRuns = workflow?.queuedRuns ?? [];
+ const order = queuedRuns.map(({ run }) => run.id).join(",");
+
+ useEffect(() => {
+ if (drag.current && drag.current.order !== order) {
+ drag.current = null;
+ setDraggedRunId(null);
+ setPreviewBeforeRunId(undefined);
+ setDragRows(null);
+ translation.setValue(0);
+ }
+ }, [order, translation]);
+
+ // Nothing left to manage: the sheet closes rather than sitting on an empty
+ // list the user has to dismiss themselves.
+ const hadQueuedRuns = useRef(queuedRuns.length > 0);
+ useEffect(() => {
+ if (queuedRuns.length > 0) {
+ hadQueuedRuns.current = true;
+ return;
+ }
+ if (hadQueuedRuns.current) navigation.goBack();
+ }, [navigation, queuedRuns.length]);
+
+ const move = async (runId: RunId, beforeRunId: RunId | null) => {
+ if (busyRef.current || !workflow?.canReorder) return;
+ busyRef.current = true;
+ setBusyRunId(runId);
+ void Haptics.selectionAsync();
+ try {
+ await reorder({ ...target, input: { threadId: target.threadId, runId, beforeRunId } });
+ } finally {
+ busyRef.current = false;
+ setBusyRunId(null);
+ }
+ };
+
+ const act = async (runId: RunId, action: QueueAction) => {
+ if (busyRef.current) return;
+ const index = queuedRuns.findIndex(({ run }) => run.id === runId);
+ if (index < 0) return;
+ if (action === "up" && index > 0) {
+ await move(runId, queuedRuns[index - 1]!.run.id);
+ return;
+ }
+ if (action === "down" && index < queuedRuns.length - 1) {
+ await move(runId, queuedRuns[index + 2]?.run.id ?? null);
+ return;
+ }
+ if (action === "edit") {
+ const entry = queuedRuns[index]!;
+ void Haptics.selectionAsync();
+ beginQueuedRunEdit(threadKey, {
+ runId,
+ messageId: entry.messageId,
+ originalText: entry.text,
+ existingAttachments: entry.attachments,
+ ...(entry.context ? { context: entry.context } : {}),
+ });
+ navigation.goBack();
+ return;
+ }
+ if (action !== "steer" && action !== "remove") return;
+ busyRef.current = true;
+ setBusyRunId(runId);
+ void Haptics.selectionAsync();
+ try {
+ if (action === "remove") {
+ await cancel(buildCancelQueuedRunCommand({ ...target, runId }));
+ } else if (workflow?.activeRun && workflow.canPromoteToSteer) {
+ await promote({
+ ...target,
+ input: {
+ threadId: target.threadId,
+ queuedRunId: runId,
+ targetRunId: workflow.activeRun.id,
+ },
+ });
+ }
+ } finally {
+ busyRef.current = false;
+ setBusyRunId(null);
+ }
+ };
+
+ const canReorder = workflow?.canReorder === true && queuedRuns.length > 1;
+ const sourceLayout = dragRows?.find((row) => row.id === draggedRunId);
+ const lastLayout = dragRows?.at(-1);
+ const insertionOffset =
+ previewBeforeRunId === undefined
+ ? undefined
+ : previewBeforeRunId === null
+ ? lastLayout?.y === undefined || lastLayout.height === undefined
+ ? undefined
+ : lastLayout.y + lastLayout.height
+ : dragRows?.find((row) => row.id === previewBeforeRunId)?.y;
+ const queueRows = () =>
+ queuedRuns.map(({ run }) => ({ id: run.id, ...rowLayouts.current.get(run.id) }));
+ const content = (
+
+ {workflow?.isHeld && queuedRuns.length > 0 ? (
+
+ Queue held after restart
+ {
+ if (busyRef.current) return;
+ busyRef.current = true;
+ setResuming(true);
+ try {
+ await resume({ ...target, input: { threadId: target.threadId } });
+ } finally {
+ busyRef.current = false;
+ setResuming(false);
+ }
+ }}
+ />
+
+ ) : null}
+ {queuedRuns.length === 0 ? (
+
+ No messages waiting in this queue.
+
+ ) : null}
+ {queuedRuns.map(({ run, text, attachments }, index) => {
+ const layout = dragRows?.find((row) => row.id === run.id);
+ const offset =
+ sourceLayout?.y !== undefined &&
+ sourceLayout.height !== undefined &&
+ layout?.y !== undefined &&
+ insertionOffset !== undefined
+ ? threadDragGapOffset(layout.y, sourceLayout.y, sourceLayout.height, insertionOffset)
+ : 0;
+ const controls = resolveThreadQueueRowControls({
+ busy: busyRunId !== null || draggedRunId !== null,
+ canPromoteToSteer: workflow?.canPromoteToSteer ?? false,
+ canReorder: workflow?.canReorder ?? false,
+ index,
+ isEditing: editing?.runId === run.id,
+ queuedCount: queuedRuns.length,
+ text,
+ });
+ const title =
+ controls.displayText || (attachments.length > 0 ? "Attachments" : "Queued message");
+ return (
+ rowLayouts.current.set(run.id, nativeEvent.layout)}
+ >
+
+ {canReorder ? (
+ // Outside the swipeable: two pans on one row would race, and
+ // the handle owns vertical movement while the row owns sideways.
+ void act(run.id, action)}
+ onStart={() => {
+ const rows = queueRows();
+ const beforeRunId = resolveQueueDragBeforeRunId(rows, run.id, 0);
+ drag.current = { runId: run.id, order, beforeRunId, rows };
+ translation.setValue(0);
+ setDragRows(rows);
+ setPreviewBeforeRunId(beforeRunId);
+ setDraggedRunId(run.id);
+ void Haptics.selectionAsync();
+ }}
+ onMove={(y) => {
+ const current = drag.current;
+ if (current?.runId !== run.id || current.order !== order) return;
+ translation.setValue(y);
+ const before = resolveQueueDragBeforeRunId(current.rows, run.id, y);
+ if (current.beforeRunId !== before) {
+ current.beforeRunId = before;
+ setPreviewBeforeRunId(before);
+ }
+ }}
+ onEnd={(y, success) => {
+ const started = drag.current;
+ const stop = () => {
+ if (drag.current !== started) return;
+ drag.current = null;
+ setDraggedRunId(null);
+ setPreviewBeforeRunId(undefined);
+ setDragRows(null);
+ translation.setValue(0);
+ };
+ // A remote reorder or a newly started run invalidates this drag.
+ if (!success || started?.order !== order || started.runId !== run.id) {
+ stop();
+ return;
+ }
+ const before = resolveQueueDropBeforeRunId(started.rows, run.id, y);
+ if (before === undefined) {
+ stop();
+ return;
+ }
+ const source = started.rows.find((row) => row.id === run.id);
+ const tail = started.rows.at(-1);
+ const insertion =
+ before === null
+ ? tail?.y !== undefined && tail.height !== undefined
+ ? tail.y + tail.height
+ : undefined
+ : started.rows.find((row) => row.id === before)?.y;
+ if (
+ source?.y !== undefined &&
+ source.height !== undefined &&
+ insertion !== undefined
+ ) {
+ Animated.timing(translation, {
+ toValue: insertion - source.y - (insertion > source.y ? source.height : 0),
+ duration: 160,
+ useNativeDriver: true,
+ }).start();
+ }
+ setPreviewBeforeRunId(before);
+ void move(run.id, before).finally(stop);
+ }}
+ />
+ ) : null}
+ void act(run.id, "remove")}
+ >
+
+ void act(run.id, nativeEvent.event as QueueAction)
+ }
+ >
+ void act(run.id, "edit")}
+ className="min-h-14 flex-row items-center gap-2.5 py-2.5 active:opacity-70"
+ >
+
+
+ {title}
+
+ {controls.isEditing ? (
+
+ Editing
+
+ ) : null}
+ {workflow?.canPromoteToSteer ? (
+ void act(run.id, "steer")}
+ className="h-8 shrink-0 justify-center rounded-full bg-primary px-3 active:opacity-70 disabled:opacity-40"
+ >
+
+ Steer
+
+
+ ) : null}
+
+
+
+
+
+ );
+ })}
+
+ );
+
+ if (Platform.OS === "ios") {
+ // A plain formSheet screen never renders a stack header, so it comes from
+ // a nested native stack inside the sheet (same shape as the git sheet).
+ return (
+
+
+
+
+ {content}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ navigation.goBack()} />
+ {content}
+
+
+ );
+}
+
+function QueueShiftedRow(props: {
+ readonly offset: number;
+ readonly dragging: boolean;
+ readonly lifted: boolean;
+ readonly onLayout: React.ComponentProps["onLayout"];
+ readonly children: React.ReactNode;
+}) {
+ const { dragging, offset } = props;
+ const style = useAnimatedStyle(() => ({
+ transform: [
+ {
+ translateY: dragging
+ ? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System })
+ : offset,
+ },
+ ],
+ }));
+ return (
+
+ {props.children}
+
+ );
+}
+
+/** Swipe left to remove, the one destructive action that needs no menu. */
+function QueueRowSwipeable(props: {
+ readonly enabled: boolean;
+ readonly background: string;
+ readonly onRemove: () => void;
+ readonly children: React.ReactNode;
+}) {
+ const swipeableRef = useRef(null);
+ return (
+ {
+ if (direction !== "right") return;
+ swipeableRef.current?.close();
+ props.onRemove();
+ }}
+ renderRightActions={() => (
+
+
+ Remove
+
+ )}
+ >
+ {props.children}
+
+ );
+}
+
+function QueueAttachmentThumbnails(props: {
+ readonly environmentId: EnvironmentId;
+ readonly attachments: ReadonlyArray;
+}) {
+ const images = props.attachments.filter((attachment) => attachment.mimeType.startsWith("image/"));
+ const shown = images.slice(0, THUMBNAIL_LIMIT);
+ const overflow = props.attachments.length - shown.length;
+ if (props.attachments.length === 0) return null;
+ return (
+
+ {shown.map((attachment) => (
+
+ ))}
+ {overflow > 0 ? (
+
+
+ {shown.length === 0 ? `${overflow}` : `+${overflow}`}
+
+
+ ) : null}
+
+ );
+}
+
+function QueueAttachmentThumbnail(props: {
+ readonly environmentId: EnvironmentId;
+ readonly attachment: ChatAttachment;
+}) {
+ const url = useAssetUrl(props.environmentId, {
+ _tag: "attachment",
+ attachmentId: props.attachment.id,
+ fileName: props.attachment.name,
+ mimeType: props.attachment.mimeType,
+ disposition: "inline",
+ });
+ if (url === null) {
+ return ;
+ }
+ return (
+
+ );
+}
+
+function QueueDragHandle(props: {
+ disabled: boolean;
+ title: string;
+ canMoveUp: boolean;
+ canMoveDown: boolean;
+ onStep: (action: "up" | "down") => void;
+ onStart: () => void;
+ onMove: (y: number) => void;
+ onEnd: (y: number, success: boolean) => void;
+}) {
+ const latest = useRef(props);
+ useLayoutEffect(() => {
+ latest.current = props;
+ });
+ const gesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .enabled(!props.disabled)
+ .minDistance(0)
+ .shouldCancelWhenOutside(false)
+ .runOnJS(true)
+ .onStart(() => latest.current.onStart())
+ .onUpdate((event) => latest.current.onMove(event.translationY))
+ .onFinalize((event, success) => latest.current.onEnd(event.translationY, success)),
+ [props.disabled],
+ );
+ return (
+
+ {
+ if (props.disabled) return;
+ if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
+ if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
+ }}
+ className="h-12 w-8 items-center justify-center"
+ >
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 4fb22d152496..72d868677ba8 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -1,9 +1,5 @@
import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
-import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
-import {
- getComposerDraftSnapshot,
- clearComposerDraftContent,
-} from "../../state/use-composer-drafts";
+import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
import { useWorktreeSetup } from "./use-worktree-setup";
import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
import { ScreenHeader } from "../../components/ScreenHeader";
@@ -19,17 +15,11 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import * as Option from "effect/Option";
import {
- CommandId,
- MessageId,
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ThreadId,
type ProjectScript,
} from "@t3tools/contracts";
-import {
- requestOlderThreadTurns,
- threadHasOlderTurns,
-} from "@t3tools/client-runtime/state/threads";
import {
projectScriptCwd,
projectScriptRuntimeEnv,
@@ -37,8 +27,8 @@ import {
} from "@t3tools/shared/projectScripts";
import { Alert, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useWorkspaceState } from "../../state/workspace";
-import { useEnvironmentShellState } from "../../state/shell";
+import { useConnectionsReady } from "../../state/workspace";
+import { useEnvironmentShellReadiness } from "../../state/shell";
import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key";
import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation";
import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft";
@@ -77,6 +67,8 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
+import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
+import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
import { threadEnvironment } from "../../state/threads";
import { projectThreadContentPresentation } from "./threadContentPresentation";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
@@ -105,7 +97,7 @@ function ThreadHeader(
) {
const navigation = useNavigation();
const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
- const { onOpenTerminal } = props.gitControls;
+ const { onOpenTerminal, onMergeBack } = props.gitControls;
const native = useThreadHeaderOptions(props);
const androidHeaderActions = useMemo>(() => {
const actions: ScreenHeaderAction[] = [];
@@ -137,12 +129,20 @@ function ThreadHeader(
icon: "point.topleft.down.curvedto.point.bottomright.up",
onPress: props.onOpenGitInspector,
});
+ if (onMergeBack) {
+ actions.push({
+ accessibilityLabel: "Merge back to source",
+ icon: "arrow.triangle.merge",
+ onPress: onMergeBack,
+ });
+ }
return actions;
}, [
props.inspectorMode,
panes.auxiliaryPaneVisible,
props.onOpenFilesInspector,
onOpenTerminal,
+ onMergeBack,
props.onOpenGitInspector,
toggleAuxiliaryPane,
props.onReturnToThread,
@@ -249,7 +249,7 @@ function ThreadUnavailableScreen(props: {
}
export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
- const { state: workspaceState } = useWorkspaceState();
+ const connectionsReady = useConnectionsReady();
const { connectionState } = useRemoteConnectionStatus();
const { selectedThread } = useThreadSelection();
const params = props.route.params;
@@ -257,7 +257,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
const threadIdRaw = firstRouteParam(params.threadId);
const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
- const routeEnvironmentShellState = useEnvironmentShellState(environmentId);
+ const routeEnvironmentShellState = useEnvironmentShellReadiness(environmentId);
const { onReconnectEnvironment } = useRemoteConnections();
const navigation = useNavigation();
const routeConnectionState =
@@ -286,10 +286,10 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
}
const stillHydrating = threadRouteIsHydrating({
- isLoadingConnections: workspaceState.isLoadingConnections,
+ isLoadingConnections: !connectionsReady,
connectionState: routeConnectionState,
shellStatus: routeEnvironmentShellState.status,
- shellHasError: Option.isSome(routeEnvironmentShellState.error),
+ shellHasError: routeEnvironmentShellState.hasError,
detailStatus: selectedThreadDetailState.status,
detailHasError: Option.isSome(selectedThreadDetailState.error),
});
@@ -336,27 +336,71 @@ function ThreadRouteContent(
} = useThreadSelection();
const selectedThreadDetailState = props.selectedThreadDetailState;
const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
- // "Load earlier turns" header state for windowed (paginated) thread loads.
- const loadEarlierTurns = useMemo(() => {
- if (selectedThread === null || !threadHasOlderTurns(selectedThreadDetailState)) {
- return null;
- }
- return {
- loading:
- selectedThreadDetailState.page._tag === "Some" &&
- selectedThreadDetailState.page.value.loadingOlder,
- onLoadEarlier: () => {
- requestOlderThreadTurns(selectedThread.environmentId, selectedThread.id);
- },
- };
- }, [selectedThread, selectedThreadDetailState]);
const { selectedThreadCwd } = useSelectedThreadWorktree();
const composer = useThreadComposerState();
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
+ const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
+ label: "load earlier thread history",
+ reportFailure: false,
+ });
+ const historyControls = useMemo(() => {
+ const history = selectedThreadDetailState.history;
+ if (!selectedThread) {
+ return undefined;
+ }
+ if (!history.hasMoreHistory && history.error === null) {
+ return undefined;
+ }
+ return {
+ hasMoreHistory: history.hasMoreHistory,
+ loading: history.loading,
+ error: history.error,
+ onLoadEarlier: () => {
+ void loadEarlierHistory({
+ environmentId: selectedThread.environmentId,
+ input: { threadId: selectedThread.id },
+ });
+ },
+ };
+ }, [loadEarlierHistory, selectedThread, selectedThreadDetailState.history]);
const navigation = useNavigation();
+ const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back");
+ const mergeBackTargetThreadId = resolveMergeBackTargetThreadId(selectedThreadDetail);
+ const mergeBackRun =
+ selectedThreadDetail === null ? null : resolveLatestMergeBackRun(selectedThreadDetail);
+ const mergeBackBusyRef = useRef(false);
+ const handleMergeBack = useCallback(async () => {
+ if (
+ mergeBackBusyRef.current ||
+ !selectedThread ||
+ mergeBackTargetThreadId === null ||
+ mergeBackRun === null
+ ) {
+ return;
+ }
+ mergeBackBusyRef.current = true;
+ try {
+ const result = await mergeBack({
+ environmentId: selectedThread.environmentId,
+ input: {
+ sourceThreadId: selectedThread.id,
+ targetThreadId: mergeBackTargetThreadId,
+ runId: mergeBackRun.id,
+ creationSource: "mobile",
+ },
+ });
+ if (result._tag !== "Success") return;
+ navigation.navigate("Thread", {
+ environmentId: selectedThread.environmentId,
+ threadId: mergeBackTargetThreadId,
+ });
+ } finally {
+ mergeBackBusyRef.current = false;
+ }
+ }, [mergeBack, mergeBackRun, mergeBackTargetThreadId, navigation, selectedThread]);
const params = props.route.params;
const environmentIdRaw = firstRouteParam(params.environmentId);
const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
@@ -467,7 +511,7 @@ function ThreadRouteContent(
}),
[knownTerminalSessions, selectedThreadProject?.workspaceRoot],
);
- const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null;
+ const selectedThreadDetailWorktreePath = selectedThreadDetail?.thread.worktreePath ?? null;
const handleReconnectEnvironment = useCallback(() => {
if (!environmentId) {
return;
@@ -630,23 +674,17 @@ function ThreadRouteContent(
void navigation.navigate("Connections");
}, [navigation]);
const handleStopThread = useCallback(() => {
- if (
- !selectedThread ||
- (selectedThread.session?.status !== "running" &&
- selectedThread.session?.status !== "starting")
- ) {
+ if (!selectedThread || composer.interruptibleRunId === null) {
return;
}
return interruptThreadTurn({
environmentId: selectedThread.environmentId,
input: {
threadId: selectedThread.id,
- ...(selectedThread.session.activeTurnId
- ? { turnId: selectedThread.session.activeTurnId }
- : {}),
+ runId: composer.interruptibleRunId,
},
});
- }, [interruptThreadTurn, selectedThread]);
+ }, [composer.interruptibleRunId, interruptThreadTurn, selectedThread]);
const handleOpenTerminal = useCallback(
(nextTerminalId?: string | null) => {
@@ -772,6 +810,10 @@ function ThreadRouteContent(
onOpenFilesInspector:
fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined,
onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined,
+ onMergeBack:
+ mergeBackTargetThreadId !== null && mergeBackRun !== null
+ ? () => void handleMergeBack()
+ : undefined,
currentBranch: selectedThread?.branch ?? null,
gitStatus: gitStatus.data,
gitOperationLabel: gitState.gitOperationLabel,
@@ -821,23 +863,28 @@ function ThreadRouteContent(
}),
);
}, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]);
- const worktreeSetup = useWorktreeSetup({
+ const setupTurnStartedAt = composer.selectedThreadActivityRun?.startedAt ?? null;
+ const { snapshot: worktreeSetupSnapshot, visible: worktreeSetup } = useWorktreeSetup({
environmentId: selectedThread?.environmentId ?? null,
threadId: selectedThread?.id ?? null,
- activities: selectedThreadDetail?.activities ?? [],
preparing:
- selectedThreadCreation?.message.creation?.workspaceMode === "worktree" &&
- selectedThreadCreation.outcome == null,
- turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null,
+ composer.selectedThreadActivityRun?.status === "preparing" ||
+ selectedThread?.runtime?.status === "preparing" ||
+ selectedThread?.worktreePath != null ||
+ (selectedThreadCreation?.message.creation?.workspaceMode === "worktree" &&
+ selectedThreadCreation.outcome == null),
+ turnStarted: setupTurnStartedAt !== null,
followUpSent:
composer.selectedThreadFeed.filter(
(entry) => entry.type === "message" && entry.message.role === "user",
).length +
- composer.selectedThreadQueuedMessages.length >
+ composer.selectedThreadQueueCount >
1,
});
const awaitingBootstrapTurn =
- worktreeSetup?.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup);
+ worktreeSetup !== null
+ ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup)
+ : (selectedThreadDetail?.runs.some((run) => run.status === "preparing") ?? false);
const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup);
const handleCancelWorktreeSetup = useCallback(() => {
if (!selectedThread) return;
@@ -846,75 +893,59 @@ function ThreadRouteContent(
input: { threadId: selectedThread.id },
});
}, [cancelWorktreeSetup, selectedThread]);
- const [localResendMessageId, setLocalResendMessageId] = useState(null);
+ const startLocalThread = useAtomCommand(threadEnvironment.startTurn, "work locally");
+ const localResendBusy = useRef(false);
+ const setupMessage = selectedThreadDetail?.messages.find((message) => message.role === "user");
const handleWorkLocally = useCallback(async () => {
- if (!selectedThread || !selectedThreadCreation) return;
- const result = await cancelWorktreeSetup({
- environmentId: selectedThread.environmentId,
- input: { threadId: selectedThread.id },
- });
- if (result._tag === "Success" && result.value.cancelled) {
- setLocalResendMessageId(selectedThreadCreation.message.messageId);
- }
- }, [cancelWorktreeSetup, selectedThread, selectedThreadCreation]);
- // Wait for the outbox to restore the cancelled send before queuing its replacement.
- useEffect(() => {
- const pending = selectedThreadCreation;
- if (
- !localResendMessageId ||
- pending?.message.messageId !== localResendMessageId ||
- pending.outcome?.kind !== "failed"
- )
+ if (!selectedThread || !selectedThreadProject || !setupMessage || localResendBusy.current)
return;
- setLocalResendMessageId(null);
- const original = pending.message;
- if (!original.creation) return;
- const metadata = makeTurnCommandMetadata();
- const replacement = {
- ...original,
- commandId: CommandId.make(metadata.commandId),
- messageId: MessageId.make(metadata.messageId),
- threadId: ThreadId.make(metadata.threadId),
- createdAt: metadata.createdAt,
- creation: {
- ...original.creation,
- workspaceMode: "local" as const,
- branch: null,
- worktreePath: null,
- },
- };
- void enqueueThreadOutboxMessage(replacement)
- .then(() => {
- const draftKey = restoredNewTaskDraftKey(original.messageId);
- const restored = getComposerDraftSnapshot(draftKey);
- // Leave any edits made during cancellation in their recovery draft.
- if (
- restored.text === original.text &&
- JSON.stringify(restored.context) === JSON.stringify(original.context) &&
- restored.attachments.length === original.attachments.length &&
- restored.attachments.every(
- (attachment, index) => attachment.id === original.attachments[index]?.id,
- )
- ) {
- clearComposerDraftContent(draftKey, { deferAttachmentCleanup: true });
- }
- clearPendingThreadCreationOutcome(
- scopedThreadKey(original.environmentId, original.threadId),
- );
- navigation.dispatch(
- StackActions.replace("Thread", {
- environmentId: String(replacement.environmentId),
- threadId: String(replacement.threadId),
- }),
- );
- })
- .catch((error) =>
- Alert.alert(
- "Could not work locally",
- error instanceof Error ? error.message : String(error),
- ),
+ localResendBusy.current = true;
+ try {
+ const result = await cancelWorktreeSetup({
+ environmentId: selectedThread.environmentId,
+ input: { threadId: selectedThread.id },
+ });
+ if (result._tag !== "Success" || !result.value.cancelled) return;
+ // V2 accepts the launch before setup runs, so cancellation never rejects
+ // the original outbox delivery. Reuse the server-owned prompt and uploads.
+ const metadata = makeTurnCommandMetadata();
+ const launched = await startLocalThread({
+ environmentId: selectedThread.environmentId,
+ input: buildProjectThreadStartTurnInput({
+ ...metadata,
+ projectId: selectedThread.projectId,
+ projectCwd: selectedThreadProject.workspaceRoot,
+ text: setupMessage.text,
+ ...(setupMessage.context ? { context: setupMessage.context } : {}),
+ uploadedAttachments: setupMessage.attachments,
+ modelSelection: selectedThread.modelSelection,
+ runtimeMode: selectedThread.runtimeMode,
+ interactionMode: selectedThread.interactionMode,
+ workspaceMode: "local",
+ branch: null,
+ worktreePath: null,
+ startFromOrigin: false,
+ worktreeBranchName: "",
+ }),
+ });
+ if (launched._tag !== "Success") return;
+ navigation.dispatch(
+ StackActions.replace("Thread", {
+ environmentId: String(selectedThread.environmentId),
+ threadId: metadata.threadId,
+ }),
);
- }, [localResendMessageId, navigation, selectedThreadCreation]);
+ } finally {
+ localResendBusy.current = false;
+ }
+ }, [
+ cancelWorktreeSetup,
+ navigation,
+ selectedThread,
+ selectedThreadProject,
+ setupMessage,
+ startLocalThread,
+ ]);
const creationState = ((): ThreadDetailScreenProps["creationState"] => {
if (selectedThreadCreation === null) {
return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null;
@@ -966,14 +997,20 @@ function ThreadRouteContent(
feedbackSubmissions={composer.feedbackSubmissions}
onDismissFeedback={composer.dismissFeedback}
selectedThreadFeed={composer.selectedThreadFeed}
- activeWorkStartedAt={composer.activeWorkStartedAt}
+ activityRun={composer.selectedThreadActivityRun}
+ activeWorkStartedAt={
+ creationState?.kind === "preparing" ||
+ (worktreeSetup !== null && setupTurnStartedAt === null)
+ ? null
+ : composer.activeWorkStartedAt
+ }
isCompacting={composer.isCompacting}
+ runlessWorkActive={composer.runlessWorkActive}
+ providerSubagentStatus={composer.providerSubagentStatus}
creationState={creationState}
setupWorkingStartedAt={
composer.activeWorkStartedAt !== null &&
- selectedThreadDetail?.activities.some(
- (activity) => activity.kind === "worktree-setup",
- ) &&
+ worktreeSetupSnapshot !== null &&
composer.selectedThreadFeed.filter(
(entry) => entry.type === "message" && entry.message.role === "user",
).length <= 1
@@ -984,14 +1021,11 @@ function ThreadRouteContent(
worktreeSetup
? {
snapshot: worktreeSetup,
- turnStartedAt: selectedThreadDetail?.latestTurn?.startedAt ?? null,
+ turnStartedAt: setupTurnStartedAt,
working: composer.activeWorkStartedAt !== null,
- turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null,
+ turnStarted: setupTurnStartedAt !== null,
onCancel: handleCancelWorktreeSetup,
- onWorkLocally:
- selectedThreadCreation?.outcome == null && selectedThreadCreation
- ? handleWorkLocally
- : null,
+ onWorkLocally: setupMessage && selectedThreadProject ? handleWorkLocally : null,
}
: null
}
@@ -1005,7 +1039,16 @@ function ThreadRouteContent(
draftAttachments={composer.draftAttachments}
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
- loadEarlier={loadEarlierTurns}
+ historyControls={historyControls}
+ activeThreadBusy={composer.activeThreadBusy}
+ canStopThread={awaitingBootstrapTurn || composer.interruptibleRunId !== null}
+ queuedRunEdit={composer.queuedRunEdit}
+ composerDraftKey={composer.composerDraftKey}
+ followUpBehavior={composer.followUpBehavior}
+ canSteerActiveTurn={composer.canSteerActiveTurn}
+ isSavingQueuedEdit={composer.isSavingQueuedEdit}
+ onCancelQueuedRunEdit={composer.cancelQueuedRunEdit}
+ onRemoveQueuedEditAttachment={composer.onRemoveQueuedEditAttachment}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
@@ -1025,6 +1068,7 @@ function ThreadRouteContent(
onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
onSendMessage={composer.onSendMessage}
onReconnectEnvironment={handleReconnectEnvironment}
+ canSwitchThreadProvider={composer.canSwitchThreadProvider}
onUpdateThreadModelSelection={composer.onUpdateModelSelection}
onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode}
onUpdateThreadInteractionMode={composer.onUpdateInteractionMode}
diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
index 235d6c1e0f25..f029bbabf258 100644
--- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
@@ -55,6 +55,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection } from "../../lib/providerOptions";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { rememberModelOptions } from "../../state/use-model-option-memory";
import {
NativeHeaderToolbar,
NativeStackScreenOptions,
@@ -77,7 +78,11 @@ import {
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
import { ModelRow, ChoiceRow } from "./ThreadSettingsRows";
-import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";
+import {
+ compatibleRuntimeModeForChoices,
+ runtimeModeChoicesForSupportedModes,
+ selectableChoices,
+} from "./thread-settings-options";
import {
canCommitPendingModel,
favoritesFirst,
@@ -124,6 +129,7 @@ const FAVORITES_PROVIDER_FILTER = "@favorites";
/** Provider catalog header with its harness logo and disclosure state. */
function ProviderHeader(props: {
readonly driver: string | undefined;
+ readonly iconUrl: string | undefined;
readonly label: string;
readonly collapsible: boolean;
readonly collapsed: boolean;
@@ -132,7 +138,7 @@ function ProviderHeader(props: {
}) {
const content = (
<>
-
+
{props.label}
{props.collapsible ? (
<>
@@ -295,6 +301,7 @@ type ThreadSettingsSessionValue = {
readonly favoritesLoaded: boolean;
readonly toggleFavorite: (option: ModelOption) => void;
readonly runtimeMode: RuntimeMode;
+ readonly runtimeModeChoices: ReturnType;
readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
readonly displayedDescriptors: ReadonlyArray;
readonly providerExpansionOverrides: ReadonlySet;
@@ -383,6 +390,20 @@ function ThreadSettingsSessionProvider(
: props.optionDescriptors,
[pendingModel, props.optionDescriptors],
);
+ const displayedModel = useMemo(
+ () =>
+ pendingModel ??
+ props.providerGroups.flatMap((group) => group.models).find((option) => isApplied(option)) ??
+ null,
+ [isApplied, pendingModel, props.providerGroups],
+ );
+ const runtimeModeChoices = runtimeModeChoicesForSupportedModes(
+ displayedModel?.supportedRuntimeModes,
+ );
+ const compatibleRuntimeMode = compatibleRuntimeModeForChoices(
+ props.runtimeMode,
+ runtimeModeChoices,
+ );
const hasLegacyModels = useMemo(
() => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)),
@@ -410,6 +431,7 @@ function ThreadSettingsSessionProvider(
return;
}
if (pendingModel) {
+ rememberModelOptions(pendingModel.selection.instanceId, pendingModel.selection.model, next);
setPendingModel({
...pendingModel,
selection: { ...pendingModel.selection, options: next },
@@ -450,7 +472,8 @@ function ThreadSettingsSessionProvider(
environmentId: props.environmentId,
providerInstanceId: props.providerInstanceId,
providerGroups: props.providerGroups,
- runtimeMode: props.runtimeMode,
+ runtimeMode: compatibleRuntimeMode,
+ runtimeModeChoices,
onUpdateRuntimeMode: props.onUpdateRuntimeMode,
displayedDescriptors,
favoriteKeys,
@@ -475,6 +498,7 @@ function ThreadSettingsSessionProvider(
[
applyOptionChange,
commitPendingModel,
+ compatibleRuntimeMode,
displayedDescriptors,
favoriteKeys,
favoritesLoaded,
@@ -489,7 +513,7 @@ function ThreadSettingsSessionProvider(
providerFilter,
props.onUpdateRuntimeMode,
props.providerGroups,
- props.runtimeMode,
+ runtimeModeChoices,
searchQuery,
showLegacyToggle,
toggleProvider,
@@ -515,6 +539,7 @@ function useThreadSettingsSession() {
type ThreadSettingsProviderCatalog = {
readonly key: string;
readonly driver: string | undefined;
+ readonly iconUrl: string | undefined;
readonly label: string;
readonly collapsible: boolean;
readonly collapsed: boolean;
@@ -583,6 +608,7 @@ function ThreadSettingsProviderListHeader(props: {
collapsible={props.provider.collapsible}
collapsed={props.provider.collapsed}
driver={props.provider.driver}
+ iconUrl={props.provider.iconUrl}
label={props.provider.label}
modelCount={props.provider.modelCount}
onToggle={onToggle}
@@ -644,6 +670,7 @@ function useThreadSettingsCatalogItems(
const provider: ThreadSettingsProviderCatalog = {
key: group.providerKey,
driver,
+ iconUrl: group.models[0]?.providerIconUrl,
label: group.providerLabel,
collapsible,
collapsed,
@@ -742,7 +769,8 @@ function ThreadSettingsOptionsItem(props: {
isLast
label="Runtime"
value={
- RUNTIME_MODE_CHOICES.find((choice) => choice.mode === session.runtimeMode)?.label
+ session.runtimeModeChoices.find((choice) => choice.mode === session.runtimeMode)
+ ?.label
}
onPress={() => props.onOpenSubmenu({ kind: "runtime" })}
/>
@@ -951,7 +979,7 @@ function ThreadSettingsChoiceContent(props: {
const submenuContent =
props.submenu.kind === "runtime"
? {
- rows: RUNTIME_MODE_CHOICES.map((choice) => ({
+ rows: session.runtimeModeChoices.map((choice) => ({
id: choice.mode,
label: choice.label,
description: choice.description,
@@ -1332,6 +1360,17 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
);
}
+/** Shared model catalog and option screens, bound to the caller's draft. */
+export function ThreadSettingsPickerScreen(
+ props: ThreadSettingsSessionProps & { readonly onClose: () => void },
+) {
+ return (
+
+
+
+ );
+}
+
/** Existing-thread model picker hosted by the root RNS form-sheet route. */
export function ExistingThreadSettingsRouteScreen() {
const navigation = useNavigation>>();
@@ -1352,11 +1391,7 @@ export function ExistingThreadSettingsRouteScreen() {
const { ownerId: _ownerId, ...settings } = session;
- return (
-
- navigation.goBack()} />
-
- );
+ return navigation.goBack()} />;
}
/**
@@ -1377,7 +1412,7 @@ export function NewTaskThreadSettingsRouteScreen() {
);
return (
-
- navigation.goBack()} />
-
+ onClose={() => navigation.goBack()}
+ />
);
}
diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
new file mode 100644
index 000000000000..0b902e25db60
--- /dev/null
+++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
@@ -0,0 +1,107 @@
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import type { EnvironmentId } from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import { useState } from "react";
+import { Pressable, View } from "react-native";
+import { AppText as Text } from "../../components/AppText";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+
+export function UsageLimitRecoveryCard({
+ thread,
+ environmentId,
+}: {
+ thread: EnvironmentThreadShell;
+ environmentId: EnvironmentId;
+}) {
+ const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata);
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState(null);
+ const resetAt = thread.runtime?.usageLimitResetAt ?? null;
+ const canSchedule =
+ resetAt !== null &&
+ Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt);
+ const runId = thread.latestRun?.runId;
+ const recovery = thread.limitRecovery;
+ const scheduled =
+ recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume;
+ if (
+ thread.runtime?.status !== "failed" ||
+ thread.runtime.lastErrorClass !== "usage_limit" ||
+ !runId
+ )
+ return null;
+ const snoozed =
+ recovery?.snooze === true &&
+ recovery.runId === runId &&
+ recovery.resetAt === resetAt &&
+ resetAt !== null &&
+ thread.snoozedUntil !== null &&
+ Date.parse(thread.snoozedUntil) === Date.parse(resetAt);
+ async function toggle(action: "resume" | "snooze") {
+ if (!resetAt || !runId || !canSchedule) return;
+ if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) {
+ setError("The reset time has passed. Retry the thread manually.");
+ return;
+ }
+ setPending(true);
+ setError(null);
+ try {
+ const result = await updateMetadata({
+ environmentId,
+ input: {
+ threadId: thread.id,
+ limitRecovery: {
+ runId,
+ resetAt,
+ ...(action === "resume" ? { autoResume: !scheduled } : { snooze: !snoozed }),
+ },
+ },
+ });
+ if (result._tag === "Failure") throw squashAtomCommandFailure(result);
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "Could not change limit recovery.");
+ } finally {
+ setPending(false);
+ }
+ }
+ return (
+
+
+ {resetAt
+ ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.`
+ : "The provider did not report a reset time. Retry manually when your limit is available."}
+
+ {canSchedule ? (
+
+ void toggle("resume")}
+ className="self-start rounded-lg bg-subtle px-3 py-2 active:opacity-70"
+ >
+
+ {scheduled ? "Cancel auto-resume" : "Resume at reset"}
+
+
+ void toggle("snooze")}
+ className="self-start rounded-lg bg-subtle px-3 py-2 active:opacity-70"
+ >
+
+ {snoozed ? "Wake now" : "Snooze until reset"}
+
+
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/composerSendPresentation.test.ts b/apps/mobile/src/features/threads/composerSendPresentation.test.ts
new file mode 100644
index 000000000000..f36d45997f1e
--- /dev/null
+++ b/apps/mobile/src/features/threads/composerSendPresentation.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveComposerSendPresentation } from "./composerSendPresentation";
+
+const idle = {
+ editingQueuedMessage: false,
+ running: false,
+ canSteer: false,
+ followUpBehavior: "queue",
+ deliveryDeferred: false,
+} as const;
+
+describe("resolveComposerSendPresentation", () => {
+ it("sends plainly while the thread is idle", () => {
+ const presentation = resolveComposerSendPresentation(idle);
+
+ expect(presentation.label).toBe("Send");
+ expect(presentation.icon).toBe("arrow.up");
+ expect(presentation.offersFollowUpChoice).toBe(false);
+ expect(presentation.action).toBeNull();
+ });
+
+ it("says Queue while the outbox is holding the message back", () => {
+ expect(resolveComposerSendPresentation({ ...idle, deliveryDeferred: true }).label).toBe(
+ "Queue",
+ );
+ });
+
+ it("follows the configured behavior once a turn is running", () => {
+ const queueing = resolveComposerSendPresentation({
+ ...idle,
+ running: true,
+ canSteer: true,
+ followUpBehavior: "queue",
+ });
+ const steering = resolveComposerSendPresentation({
+ ...idle,
+ running: true,
+ canSteer: true,
+ followUpBehavior: "steer",
+ });
+
+ expect(queueing.label).toBe("Queue");
+ expect(queueing.icon).toBe("list.number");
+ expect(queueing.action).toBe("queue");
+ expect(queueing.alternate).toBe("steer");
+ expect(steering.label).toBe("Steer");
+ expect(steering.icon).toBe("arrow.turn.left.up");
+ expect(steering.action).toBe("steer");
+ expect(steering.alternate).toBe("queue");
+ expect(steering.offersFollowUpChoice).toBe(true);
+ });
+
+ it("never promises steering the provider cannot do", () => {
+ const presentation = resolveComposerSendPresentation({
+ ...idle,
+ running: true,
+ canSteer: false,
+ followUpBehavior: "steer",
+ });
+
+ expect(presentation.label).toBe("Queue");
+ expect(presentation.action).toBe("queue");
+ expect(presentation.alternate).toBeNull();
+ expect(presentation.offersFollowUpChoice).toBe(false);
+ });
+
+ it("keeps the save affordance while a queued message is being edited", () => {
+ const presentation = resolveComposerSendPresentation({
+ ...idle,
+ editingQueuedMessage: true,
+ running: true,
+ canSteer: true,
+ followUpBehavior: "steer",
+ });
+
+ expect(presentation.label).toBe("Update queued message");
+ expect(presentation.icon).toBe("checkmark");
+ expect(presentation.offersFollowUpChoice).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/threads/composerSendPresentation.ts b/apps/mobile/src/features/threads/composerSendPresentation.ts
new file mode 100644
index 000000000000..e8d02f4edf67
--- /dev/null
+++ b/apps/mobile/src/features/threads/composerSendPresentation.ts
@@ -0,0 +1,67 @@
+import {
+ alternateComposerDispatchAction,
+ type ActiveTurnComposerAction,
+} from "@t3tools/client-runtime/state/composer-dispatch";
+
+import type { FollowUpBehavior } from "../../lib/followUpBehavior";
+
+export interface ComposerSendPresentation {
+ readonly label: string;
+ readonly icon: "arrow.up" | "checkmark" | "list.number" | "arrow.turn.left.up";
+ /** What a plain tap does while a turn runs, or null when the turn is idle. */
+ readonly action: ActiveTurnComposerAction | null;
+ /** What the long-press menu and the Command chord do instead. */
+ readonly alternate: ActiveTurnComposerAction | null;
+ /** The follow-up menu is meaningless outside a running turn or during an edit. */
+ readonly offersFollowUpChoice: boolean;
+}
+
+const ACTION_LABEL: Record = {
+ queue: "Queue",
+ steer: "Steer",
+ restart: "Restart",
+};
+
+/**
+ * What the composer's primary button says and does. Steering is only offered
+ * when the provider can actually steer the live turn, so the button never
+ * promises something the server would have to silently downgrade.
+ */
+export function resolveComposerSendPresentation(input: {
+ readonly editingQueuedMessage: boolean;
+ readonly running: boolean;
+ readonly canSteer: boolean;
+ readonly followUpBehavior: FollowUpBehavior;
+ /** Outbox reasons the send waits rather than leaving immediately. */
+ readonly deliveryDeferred: boolean;
+}): ComposerSendPresentation {
+ if (input.editingQueuedMessage) {
+ return {
+ label: "Update queued message",
+ icon: "checkmark",
+ action: null,
+ alternate: null,
+ offersFollowUpChoice: false,
+ };
+ }
+ if (!input.running) {
+ return {
+ label: input.deliveryDeferred ? "Queue" : "Send",
+ icon: "arrow.up",
+ action: null,
+ alternate: null,
+ offersFollowUpChoice: false,
+ };
+ }
+ // Without steering support the choice collapses: every follow-up queues, so
+ // offering a menu with one usable entry would be noise.
+ const action: ActiveTurnComposerAction = input.canSteer ? input.followUpBehavior : "queue";
+ const alternate = alternateComposerDispatchAction(action);
+ return {
+ label: ACTION_LABEL[action],
+ icon: action === "steer" ? "arrow.turn.left.up" : "list.number",
+ action,
+ alternate: input.canSteer ? alternate : null,
+ offersFollowUpChoice: input.canSteer,
+ };
+}
diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx
index 9542b510b43f..3538949c9d2d 100644
--- a/apps/mobile/src/features/threads/floating-working-control.tsx
+++ b/apps/mobile/src/features/threads/floating-working-control.tsx
@@ -1,3 +1,4 @@
+import type { SubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import { GlassContainer, GlassView } from "expo-glass-effect";
import { type ReactNode, useEffect, useRef, useState } from "react";
@@ -14,6 +15,7 @@ import Animated, {
FadeIn,
FadeOut,
ReduceMotion,
+ type SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
@@ -67,14 +69,32 @@ export function FloatingWorkingControl(props: {
readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null;
readonly showScrollToEnd: boolean;
readonly onScrollToEnd: () => void;
+ readonly agents: SubagentPillSegment | null;
+ readonly onOpenAgents: () => void;
+ readonly queuedCount: number;
+ readonly onOpenQueue: () => void;
+ /** Extra distance to rise above the anchor, e.g. an overlay card's coverage. */
+ readonly lift?: SharedValue;
}) {
const { width: windowWidth } = useWindowDimensions();
const [overlayWidth, setOverlayWidth] = useState(windowWidth);
- const deviceControlWidth = props.devicePreview !== null ? CONTROL_HEIGHT : 0;
- const hasCapsule = props.status !== null || props.devicePreview !== null;
+ const [queueWidth, setQueueWidth] = useState(0);
+ const [agentsWidth, setAgentsWidth] = useState(0);
+ const hasQueue = props.queuedCount > 0;
+ const hasDevicePreview = props.devicePreview !== null;
+ const [deviceWidth, setDeviceWidth] = useState(0);
+ const agents = props.agents;
+ const hasAgents = agents !== null;
+ // Segments keep their measured width; only the status label absorbs the
+ // remainder, so a long "Working 12m 04s" truncates before a count does.
const labelWidth = Math.max(
0,
- Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16 - deviceControlWidth,
+ Math.min(overlayWidth, windowWidth) -
+ CONTROL_HEIGHT -
+ 32 -
+ (hasQueue ? queueWidth : 0) -
+ (hasAgents ? agentsWidth : 0) -
+ (hasDevicePreview ? deviceWidth : 0),
);
const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
@@ -82,6 +102,10 @@ export function FloatingWorkingControl(props: {
separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING);
}, [props.showScrollToEnd, separationProgress]);
+ const lift = props.lift;
+ const liftStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: -(lift?.value ?? 0) }],
+ }));
const arrowTransformStyle = useAnimatedStyle(() => ({
transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }],
}));
@@ -107,6 +131,7 @@ export function FloatingWorkingControl(props: {
// Forget the width while no label is shown so the next one appears at its
// own size instead of animating from the previous label's.
const hasStatus = props.status !== null;
+ const hasCapsule = hasStatus || hasQueue || hasAgents || hasDevicePreview;
useEffect(() => {
if (!hasStatus) {
measuredWidthRef.current = null;
@@ -119,29 +144,29 @@ export function FloatingWorkingControl(props: {
// Zero until the first measurement lands, so the capsule never paints around
// a label it has not sized to yet.
const capsuleSizerStyle = useAnimatedStyle(() => ({
- width: (capsuleWidth.value ?? 0) + deviceControlWidth,
+ width: capsuleWidth.value ?? 0,
}));
if (!hasCapsule && !props.showScrollToEnd) {
return null;
}
- // Only the connection label is a button (tap to reconnect); the others
- // pass touches through to the feed like before.
+ // The queue, agents, and reconnect labels have separate tap targets.
const statusInteractive = props.status?.kind === "connection";
- const capsuleInteractive = statusInteractive || props.devicePreview !== null;
+ const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasDevicePreview;
// The host stays centered on the capsule, but its measurement constraint
// comes from the overlay, independent of the capsule's current width.
- const capsuleContent =
- props.status === null && props.devicePreview !== null ? (
-
- ) : props.status !== null ? (
- <>
+ const statusContent =
+ props.status !== null ? (
+
- {props.devicePreview !== null ? (
- <>
-
-
-
-
- >
- ) : null}
- >
+
) : null;
+ const capsuleContent = (
+
+ {statusContent}
+ {props.devicePreview !== null ? (
+ setDeviceWidth(event.nativeEvent.layout.width)}
+ >
+ {hasStatus ? : null}
+
+
+ ) : null}
+ {agents !== null ? (
+ setAgentsWidth(event.nativeEvent.layout.width)}
+ className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70"
+ >
+ {hasStatus || hasDevicePreview ? : null}
+
+
+ {agents.label}
+
+
+ ) : null}
+ {hasQueue ? (
+ setQueueWidth(event.nativeEvent.layout.width)}
+ style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }}
+ className="h-11 flex-row items-center gap-2 px-3 active:opacity-70"
+ >
+ {hasStatus || hasDevicePreview || hasAgents ? (
+
+ ) : null}
+
+
+ {props.queuedCount} queued
+
+
+ ) : null}
+
+ );
+
return (
setOverlayWidth(event.nativeEvent.layout.width)}
entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}
exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}
@@ -319,6 +385,26 @@ function FloatingStatusLabel(props: {
);
}
+ if (props.status.kind === "waiting") {
+ return (
+
+
+
+ {props.status.label}
+
+
+ );
+ }
if (props.status.kind === "preparing") {
return (
void;
}) {
- const [nowMs, setNowMs] = useState(() => Date.now());
+ return (
+
+
+
+ );
+}
+export function WorkingTimer(props: { readonly startedAt: string }) {
+ const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
- setNowMs(Date.now());
const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
return () => clearInterval(intervalId);
- }, [props.startedAt]);
-
- const duration = formatWorkingDuration(props.startedAt, nowMs);
- const label = `Working for ${duration}`;
-
+ }, []);
return (
-
- Working for
-
- {duration}
-
-
+
+ Working {formatWorkingDuration(props.startedAt, nowMs)}
+
);
}
diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts
index 49abd2da98a8..428add7bf3e7 100644
--- a/apps/mobile/src/features/threads/floating-working-status.ts
+++ b/apps/mobile/src/features/threads/floating-working-status.ts
@@ -9,6 +9,8 @@ export type FloatingWorkingStatus =
| { readonly kind: "working"; readonly startedAt: string }
| { readonly kind: "syncing"; readonly label: string }
| { readonly kind: "compacting" }
+ // The turn settled while background work it started still runs.
+ | { readonly kind: "waiting"; readonly label: string; readonly accessibilityLabel: string }
// A task whose thread the server has not created yet: the worktree may
// still be checking out, so there is no turn to time.
| { readonly kind: "preparing"; readonly label: string }
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index ab598edd5d01..18c023b45ee1 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -68,6 +68,10 @@ import {
capturePendingTaskEditorWriteBaseline,
flushPendingTaskEditorWrite,
} from "../../state/pending-task-editor-writes";
+import {
+ rememberModelOptions,
+ withRememberedModelOptions,
+} from "../../state/use-model-option-memory";
import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
import { vcsEnvironment } from "../../state/vcs";
import {
@@ -244,6 +248,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const groupingSettings = useMobileProjectGroupingSettings();
const { enabled: legacyPlanModeEnabled, loaded: planModePreferenceLoaded } =
useLegacyPlanModeState();
+
const projectScopes = useMemo(
() =>
sortHomeProjectScopes({
@@ -591,7 +596,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (!option) {
return;
}
- const selection = options ? { ...option.selection, options } : option.selection;
+ const selection = withRememberedModelOptions(
+ options ? { ...option.selection, options } : option.selection,
+ );
const provider = selectedEnvironmentServerConfig?.providers.find(
(candidate) => candidate.instanceId === selection.instanceId,
);
@@ -610,6 +617,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (!selectedModel || !selectedProjectDraftKey) {
return;
}
+ rememberModelOptions(selectedModel.instanceId, selectedModel.model, options ?? []);
const nextSelection: ModelSelection = options
? { ...selectedModel, options }
: {
diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts
index 3c35eb482a3c..fdb4de20b806 100644
--- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts
+++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts
@@ -41,7 +41,7 @@ describe("pending timeline messages", () => {
it("keeps pending messages after newer agent activity in queue order", () => {
const activity = {
type: "thinking",
- turnId: null,
+ runId: null,
id: "thinking",
createdAt: "2026-09-06T11:00:00.000Z",
} as const;
diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts
index 14708314bbc7..833c30b78d9e 100644
--- a/apps/mobile/src/features/threads/pending-thread-feed.ts
+++ b/apps/mobile/src/features/threads/pending-thread-feed.ts
@@ -29,11 +29,14 @@ export function appendPendingThreadMessages(
id: pendingMessage.messageId,
role: "user",
text: pendingMessage.text,
+ attachments: [],
context: pendingMessage.context,
createdAt: pendingMessage.createdAt,
updatedAt: pendingMessage.createdAt,
- turnId: null,
+ runId: null,
streaming: false,
+ visibility: "local",
+ sourceThreadId: pendingMessage.threadId,
},
})),
];
diff --git a/apps/mobile/src/features/threads/subagent-card-presentation.test.ts b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts
new file mode 100644
index 000000000000..c4162282124d
--- /dev/null
+++ b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts
@@ -0,0 +1,44 @@
+import * as DateTime from "effect/DateTime";
+import { describe, expect, it } from "vite-plus/test";
+import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation";
+
+describe("subagent card", () => {
+ it("shows readable result text and suppresses generic completion messages", () => {
+ expect(
+ subagentCardDetail(
+ "- Updated `app.ts` with [the fix](https://example.com).\n- Checked tests.",
+ ),
+ ).toBe("Updated app.ts with the fix. Checked tests.");
+ expect(subagentCardDetail("Child task ended with status failed.")).toBeNull();
+ expect(subagentCardDetail(" ")).toBeNull();
+ });
+
+ const start = DateTime.makeUnsafe("2026-09-21T12:00:00Z");
+ const end = DateTime.makeUnsafe("2026-09-21T12:01:00Z");
+ const later = DateTime.makeUnsafe("2026-09-21T12:02:00Z");
+ const done = { status: "completed" as const, startedAt: start, completedAt: end };
+
+ it("freezes settled durations and spans the group's wall time", () => {
+ expect(subagentCardElapsed([done], DateTime.toEpochMillis(later))).toBe("1m");
+ expect(
+ subagentCardElapsed(
+ [done, { ...done, startedAt: end, completedAt: later }],
+ DateTime.toEpochMillis(later),
+ ),
+ ).toBe("2m");
+ });
+
+ it("counts live work but never uses a settled agent's age as its duration", () => {
+ const unfinished = { ...done, completedAt: null };
+ expect(subagentCardElapsed([unfinished], DateTime.toEpochMillis(later))).toBeNull();
+ expect(
+ subagentCardElapsed([{ ...unfinished, status: "running" }], DateTime.toEpochMillis(later)),
+ ).toBe("2m");
+ expect(
+ subagentCardElapsed([{ ...unfinished, status: "idle" }], DateTime.toEpochMillis(later)),
+ ).toBeNull();
+ expect(
+ subagentCardElapsed([{ ...done, startedAt: null }], DateTime.toEpochMillis(later)),
+ ).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/threads/subagent-card-presentation.ts b/apps/mobile/src/features/threads/subagent-card-presentation.ts
new file mode 100644
index 000000000000..38e2f5127113
--- /dev/null
+++ b/apps/mobile/src/features/threads/subagent-card-presentation.ts
@@ -0,0 +1,35 @@
+import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
+import type { OrchestrationV2Subagent } from "@t3tools/contracts";
+import { formatDuration } from "@t3tools/shared/orchestrationTiming";
+import * as DateTime from "effect/DateTime";
+
+export function subagentCardDetail(detail: string | null): string | null {
+ if (!detail || /^Child task ended with status\b/i.test(detail)) return null;
+ return (
+ detail
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
+ .replace(/`/g, "")
+ .replace(/^[ \t]*[-*][ \t]+/gm, "")
+ .replace(/\s+/g, " ")
+ .trim() || null
+ );
+}
+
+/** A group's wall time spans its first launch to its last completion. */
+export function subagentCardElapsed(
+ agents: ReadonlyArray>,
+ nowMs: number,
+): string | null {
+ const starts = agents.flatMap((agent) =>
+ agent.startedAt ? [DateTime.toEpochMillis(agent.startedAt)] : [],
+ );
+ if (starts.length === 0) return null;
+ const live = agents.some((agent) => isActiveSubagentStatus(agent.status));
+ // Settled agents without a completion timestamp must not keep counting their age.
+ if (!live && agents.some((agent) => agent.completedAt === null)) return null;
+ const ends = agents.flatMap((agent) =>
+ agent.completedAt ? [DateTime.toEpochMillis(agent.completedAt)] : [],
+ );
+ const duration = (live ? nowMs : Math.max(...ends)) - Math.min(...starts);
+ return duration > 0 ? formatDuration(duration) : null;
+}
diff --git a/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts b/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts
new file mode 100644
index 000000000000..5612cfbf70d5
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts
@@ -0,0 +1,43 @@
+import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ resolveThreadActivityMetadata,
+ resolveThreadActivityStatus,
+} from "./thread-activity-row-presentation";
+
+describe("thread activity row presentation", () => {
+ it("shows the provider and model as compact metadata", () => {
+ expect(
+ resolveThreadActivityMetadata({
+ providerDriver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.4",
+ }),
+ ).toBe("Codex · gpt-5.4");
+ });
+
+ it("falls back to the provider instance and removes duplicate metadata", () => {
+ expect(
+ resolveThreadActivityMetadata({
+ providerDriver: null,
+ providerInstanceId: ProviderInstanceId.make("custom-agent"),
+ model: "custom-agent",
+ }),
+ ).toBe("custom-agent");
+ });
+
+ it("maps lifecycle status to dot tone and an accessible label", () => {
+ expect(resolveThreadActivityStatus("idle")).toEqual({ label: "Idle", tone: "neutral" });
+ expect(resolveThreadActivityStatus("running")).toEqual({ label: "Running", tone: "active" });
+ expect(resolveThreadActivityStatus("completed")).toEqual({
+ label: "Completed",
+ tone: "success",
+ });
+ expect(resolveThreadActivityStatus("failed")).toEqual({ label: "Failed", tone: "danger" });
+ expect(resolveThreadActivityStatus("interrupted")).toEqual({
+ label: "Interrupted",
+ tone: "warning",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-activity-row-presentation.ts b/apps/mobile/src/features/threads/thread-activity-row-presentation.ts
new file mode 100644
index 000000000000..d5bde12ddb7f
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-activity-row-presentation.ts
@@ -0,0 +1,42 @@
+import {
+ PROVIDER_DISPLAY_NAMES,
+ type OrchestrationV2TurnItem,
+ type ProviderDriverKind,
+ type ProviderInstanceId,
+} from "@t3tools/contracts";
+
+export function resolveThreadActivityMetadata(input: {
+ readonly providerDriver: ProviderDriverKind | null;
+ readonly providerInstanceId: ProviderInstanceId | null;
+ readonly model: string | null;
+}): string {
+ const providerLabel = input.providerDriver
+ ? (PROVIDER_DISPLAY_NAMES[input.providerDriver] ?? input.providerInstanceId)
+ : input.providerInstanceId;
+ const values = [providerLabel, input.model].filter(
+ (value): value is string => value !== null && value.length > 0,
+ );
+ return [...new Set(values)].join(" · ");
+}
+
+export function resolveThreadActivityStatus(status: OrchestrationV2TurnItem["status"]): {
+ readonly label: string;
+ readonly tone: "active" | "danger" | "success" | "warning" | "neutral";
+} {
+ const label = status.charAt(0).toUpperCase() + status.slice(1).replaceAll("_", " ");
+ switch (status) {
+ case "idle":
+ return { label, tone: "neutral" };
+ case "completed":
+ return { label, tone: "success" };
+ case "failed":
+ return { label, tone: "danger" };
+ case "cancelled":
+ case "interrupted":
+ return { label, tone: "warning" };
+ case "pending":
+ case "running":
+ case "waiting":
+ return { label, tone: "active" };
+ }
+}
diff --git a/apps/mobile/src/features/threads/thread-context-divider.tsx b/apps/mobile/src/features/threads/thread-context-divider.tsx
new file mode 100644
index 000000000000..7915593db4de
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-context-divider.tsx
@@ -0,0 +1,46 @@
+import type { ReactNode } from "react";
+import { View, type ColorValue } from "react-native";
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
+import { ShimmeringWorkContent } from "./thread-work-log";
+
+export function ThreadContextDivider(props: {
+ label: string;
+ icon: AppSymbolName;
+ iconColor: ColorValue;
+ active?: boolean;
+ failed?: boolean;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
+
+ {props.active ? (
+
+ ) : (
+
+ {props.label}
+
+ )}
+ {props.children}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/thread-feed-item-size.test.ts b/apps/mobile/src/features/threads/thread-feed-item-size.test.ts
new file mode 100644
index 000000000000..d2780fff584f
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-feed-item-size.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
+
+describe("resolveThreadFeedFixedItemSize", () => {
+ it("leaves activity groups to native measurement", () => {
+ expect(resolveThreadFeedFixedItemSize("activity-group")).toBeUndefined();
+ });
+
+ it("keeps fixed timeline chrome on the premeasured path", () => {
+ expect(resolveThreadFeedFixedItemSize("run-fold")).toBe(42);
+ expect(resolveThreadFeedFixedItemSize("work-toggle")).toBe(28);
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-feed-item-size.ts b/apps/mobile/src/features/threads/thread-feed-item-size.ts
new file mode 100644
index 000000000000..b3c3ea15288b
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-feed-item-size.ts
@@ -0,0 +1,22 @@
+import { THREAD_WORK_ROW_MIN_HEIGHT } from "../../lib/layout";
+import type { ThreadFeedEntry } from "../../lib/threadActivity";
+
+// These rows are pure timeline chrome whose rendered height is independent of
+// their content. Content-driven rows must be measured by LegendList: returning
+// a fixed size makes the list skip native measurement entirely.
+const TURN_FOLD_HEIGHT = 42;
+const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
+
+export function resolveThreadFeedFixedItemSize(
+ entryType: ThreadFeedEntry["type"],
+): number | undefined {
+ switch (entryType) {
+ case "run-fold":
+ return TURN_FOLD_HEIGHT;
+ case "work-toggle":
+ return WORK_GROUP_TOGGLE_HEIGHT;
+ case "activity-group":
+ case "message":
+ return undefined;
+ }
+}
diff --git a/apps/mobile/src/features/threads/thread-handoff-row.tsx b/apps/mobile/src/features/threads/thread-handoff-row.tsx
new file mode 100644
index 000000000000..4007fe183dba
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-handoff-row.tsx
@@ -0,0 +1,114 @@
+import { useAtomValue } from "@effect/atom-react";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { resolveHandoffEndpoints } from "@t3tools/client-runtime/handoff";
+import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display";
+import type {
+ EnvironmentId,
+ OrchestrationV2ProjectedTurnItem,
+ ProviderInstanceId,
+ ServerProvider,
+} from "@t3tools/contracts";
+import { Atom } from "effect/unstable/reactivity";
+import { Fragment, useMemo } from "react";
+import { Alert, Pressable, View, type ColorValue } from "react-native";
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { ProviderIcon } from "../../components/ProviderIcon";
+import { environmentThreadDetails } from "../../state/threads";
+import { serverEnvironment } from "../../state/server";
+import { ThreadContextDivider } from "./thread-context-divider";
+
+function handoffEndpointsAtom(
+ environmentId: EnvironmentId,
+ { item, sourceThreadId }: OrchestrationV2ProjectedTurnItem,
+) {
+ let previous: ReturnType | null = null;
+ const ref = scopeThreadRef(environmentId, sourceThreadId);
+ return Atom.make((get) => {
+ if (item.type !== "handoff") return null;
+ const projection = get(environmentThreadDetails.threadAtom(ref))?.projection;
+ const next = resolveHandoffEndpoints(item, projection?.runs ?? []);
+ // Run activity changes frequently; only endpoint changes affect this row.
+ if (JSON.stringify(next) !== JSON.stringify(previous)) previous = next;
+ return previous;
+ });
+}
+
+export function ThreadHandoffRow(props: {
+ environmentId: EnvironmentId;
+ projectedItem: OrchestrationV2ProjectedTurnItem;
+ iconColor: ColorValue;
+}) {
+ const { item } = props.projectedItem;
+ const endpointsAtom = useMemo(
+ () => handoffEndpointsAtom(props.environmentId, props.projectedItem),
+ [props.environmentId, props.projectedItem],
+ );
+ const endpoints = useAtomValue(endpointsAtom);
+ const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
+ if (item.type !== "handoff" || endpoints === null) return null;
+ const color = item.status === "failed" ? "#e11d48" : props.iconColor;
+ return (
+
+
+ {endpoints.from.map((endpoint, index) => (
+
+ {index > 0 ? (
+
+ ,
+
+ ) : null}
+
+
+ ))}
+ {endpoints.from.length > 0 ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function HandoffEndpoint(props: {
+ instanceId: ProviderInstanceId;
+ model?: string | undefined;
+ providers: ReadonlyArray;
+}) {
+ const provider = props.providers.find((candidate) => candidate.instanceId === props.instanceId);
+ const model = provider?.models.find((candidate) => candidate.slug === props.model);
+ const label =
+ model?.shortName ??
+ model?.name ??
+ props.model ??
+ (provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId);
+ return (
+
+ Alert.alert(
+ label,
+ provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId,
+ )
+ }
+ >
+
+
+ {label}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index 10b01fef0ec7..e37f2da7c131 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -1,3 +1,4 @@
+import type { ThreadRowProviderInstance } from "./thread-provider-instance";
import {
THREAD_LIST_V2_MONO_FONT as MONO_FONT,
THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME,
@@ -23,13 +24,13 @@ import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps }
import { Alert, Pressable, useWindowDimensions, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
+import type { ThreadListProvider } from "../../state/thread-list-environments";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { ControlPillMenu } from "../../components/ControlPill";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
import { ProjectFavicon } from "../../components/ProjectFavicon";
-import { ProviderInstanceIcon } from "../../components/ProviderIcon";
-import type { ThreadRowProviderInstance } from "./thread-provider-instance";
+import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon";
import { cn } from "../../lib/cn";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
@@ -40,9 +41,11 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
- resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
+ resolveThreadListV2SnoozeMenuSelection,
+ threadHasUnseenCompletion,
resolveThreadListV2Status,
+ resolveThreadListV2ProviderDrivers,
resolveThreadListV2SwipeActions,
type ThreadListV2Status,
} from "./threadListV2";
@@ -66,6 +69,7 @@ const STATUS_LABEL_BY_STATUS: Partial<
input: { label: "Input", className: "text-adaptive-indigo-600-300" },
working: { label: "Working", className: "text-adaptive-sky-600-400" },
failed: { label: "Failed", className: "text-danger-foreground" },
+ limited: { label: "Limited", className: "text-warning-foreground" },
};
// Menus keep lifecycle and title regeneration together. Archive keeps its
@@ -461,6 +465,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly snoozePresetMinute: string;
readonly project: EnvironmentProject | null;
readonly projectTitle?: string;
+ /** Keep the environment's provider array stable across unrelated list updates. */
+ readonly providers: ReadonlyArray | undefined;
readonly providerInstance: ThreadRowProviderInstance | null;
/** Which machine hosts the thread. Null when only one environment is
connected — repeating the same label on every row is noise. Mirrors
@@ -548,6 +554,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const pinnedRow = props.pinned === true;
const dormant = useSwipeRowDormant(props.activationKey);
+ const { providerDrivers, providerIconUrl } = useMemo(() => {
+ const provider = props.providers?.find(
+ (candidate) =>
+ candidate.instanceId ===
+ (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId),
+ );
+ return {
+ providerDrivers: resolveThreadListV2ProviderDrivers(thread, props.providers),
+ providerIconUrl: provider?.iconUrl,
+ };
+ }, [thread, props.providers]);
+
+ const providerInstance = props.providerInstance;
const pr = useThreadPr(thread);
const theme = useUniwindTheme();
@@ -556,7 +575,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected);
const status = resolveThreadListV2Status(thread);
- const statusLabel = STATUS_LABEL_BY_STATUS[status];
+ // "Done" marks a completion the user has not opened yet — same emerald
+ // label as the web sidebar, sourced from the server-side visited watermark
+ // so checking a thread on any device clears it everywhere.
+ const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
+ const statusLabel =
+ STATUS_LABEL_BY_STATUS[status] ??
+ (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);
// The timestamp is precomputed on the list item (same stamps the settled
// tail sorts by) so a minute tick only re-renders rows that draw it.
const timeLabel = props.timeLabel;
@@ -818,8 +843,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnpin,
handleUnsettle,
handleUnsnooze,
- snoozePresets,
setCustomSnoozeOpen,
+ snoozePresets,
],
);
const primaryAction = useMemo(() => {
@@ -953,17 +978,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
) : null}
- {status === "failed" && thread.session?.lastError ? (
+ {(status === "failed" || status === "limited") && thread.runtime?.lastError ? (
- {thread.session.lastError}
+ {thread.runtime.lastError}
) : thread.branch || props.environmentLabel ? (
/* "branch · machine" share one truncating line. The machine sits
@@ -1048,15 +1075,26 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
) : null}
- {props.providerInstance ? (
-
+ {providerInstance ? (
+ // Earlier owners peek out behind the current provider so a
+ // handed-off thread shows where it has been. The current owner
+ // keeps its account badge so same-driver instances stay distinct.
+
+ {providerDrivers.slice(0, -1).map((driver, index) => (
+
+
+
+ ))}
+
+
) : null}
>
diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts
index 4912bf2b61ba..c5d797dcfc52 100644
--- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts
+++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts
@@ -35,13 +35,13 @@ function makeThread(environmentId: EnvironmentId, instanceId: string): Environme
interactionMode: "default",
branch: null,
worktreePath: null,
- latestTurn: null,
+ latestRun: null,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
archivedAt: null,
settledOverride: null,
settledAt: null,
- session: null,
+ runtime: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
@@ -64,8 +64,14 @@ describe("resolveThreadProviderInstance", () => {
const threadA = makeThread(environmentA, "codex");
const threadB = makeThread(environmentB, "codex");
- expect(resolveThreadProviderInstance(serverConfigs, threadA)?.accentColor).toBe("#ff8800");
- expect(resolveThreadProviderInstance(serverConfigs, threadB)?.accentColor).toBeUndefined();
+ expect(
+ resolveThreadProviderInstance(serverConfigs.get(environmentA)?.providers, threadA)
+ ?.accentColor,
+ ).toBe("#ff8800");
+ expect(
+ resolveThreadProviderInstance(serverConfigs.get(environmentB)?.providers, threadB)
+ ?.accentColor,
+ ).toBeUndefined();
});
it("labels a custom instance by its id so its initials differ from the default", () => {
@@ -81,14 +87,52 @@ describe("resolveThreadProviderInstance", () => {
]);
expect(
- resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex"))?.displayName,
+ resolveThreadProviderInstance(
+ serverConfigs.get(environmentId)?.providers,
+ makeThread(environmentId, "codex"),
+ )?.displayName,
).toBe("Codex");
expect(
- resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex_personal"))
- ?.displayName,
+ resolveThreadProviderInstance(
+ serverConfigs.get(environmentId)?.providers,
+ makeThread(environmentId, "codex_personal"),
+ )?.displayName,
).toBe("Codex Personal");
});
+ it("uses the current runtime owner after a provider handoff", () => {
+ const environmentId = EnvironmentId.make("environment-a");
+ const serverConfigs = new Map([
+ [
+ environmentId,
+ makeConfig([
+ { instanceId: "claudeAgent", driver: "claudeAgent" },
+ { instanceId: "codex", driver: "codex", displayName: "Codex" },
+ { instanceId: "codex_work", driver: "codex", displayName: "Codex" },
+ ]),
+ ],
+ ]);
+ const thread = {
+ ...makeThread(environmentId, "claudeAgent"),
+ runtime: {
+ status: "running" as const,
+ activeRunId: null,
+ providerInstanceId: ProviderInstanceId.make("codex_work"),
+ providerName: "Codex",
+ lastError: null,
+ updatedAt: "2026-06-01T00:01:00.000Z",
+ },
+ };
+
+ expect(
+ resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread),
+ ).toMatchObject({
+ driverKind: "codex",
+ displayName: "Codex Work",
+ showBadge: true,
+ });
+ });
+
it("hides the badge for a single instance with no accent color", () => {
const environmentId = EnvironmentId.make("environment-a");
const serverConfigs = new Map([
@@ -96,7 +140,9 @@ describe("resolveThreadProviderInstance", () => {
]);
const thread = makeThread(environmentId, "codex");
- expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
+ expect(
+ resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread)?.showBadge,
+ ).toBe(false);
});
});
@@ -113,7 +159,9 @@ describe("createThreadRowProviderInstanceResolver", () => {
]);
it("hands out the same reference for repeated lookups of one instance", () => {
- const resolve = createThreadRowProviderInstanceResolver(serverConfigs);
+ const resolve = createThreadRowProviderInstanceResolver(
+ new Map([...serverConfigs].map(([id, config]) => [id, config.providers])),
+ );
const first = resolve(makeThread(environmentId, "codex"));
const second = resolve(makeThread(environmentId, "codex"));
// Memoized rows compare props by reference: a fresh object per call would
@@ -123,7 +171,9 @@ describe("createThreadRowProviderInstanceResolver", () => {
});
it("distinguishes instances of the same driver", () => {
- const resolve = createThreadRowProviderInstanceResolver(serverConfigs);
+ const resolve = createThreadRowProviderInstanceResolver(
+ new Map([...serverConfigs].map(([id, config]) => [id, config.providers])),
+ );
const personal = resolve(makeThread(environmentId, "codex"));
const work = resolve(makeThread(environmentId, "codex_work"));
expect(personal).not.toBeNull();
@@ -133,18 +183,24 @@ describe("createThreadRowProviderInstanceResolver", () => {
});
it("hands out a new identity when the server-config generation changes", () => {
- const before = createThreadRowProviderInstanceResolver(serverConfigs);
+ const before = createThreadRowProviderInstanceResolver(
+ new Map([...serverConfigs].map(([id, config]) => [id, config.providers])),
+ );
const nextConfigs = new Map([
[environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])],
]);
- const after = createThreadRowProviderInstanceResolver(nextConfigs);
+ const after = createThreadRowProviderInstanceResolver(
+ new Map([...nextConfigs].map(([id, config]) => [id, config.providers])),
+ );
expect(after(makeThread(environmentId, "codex"))).not.toBe(
before(makeThread(environmentId, "codex")),
);
});
it("resolves unknown instances to null without throwing", () => {
- const resolve = createThreadRowProviderInstanceResolver(serverConfigs);
+ const resolve = createThreadRowProviderInstanceResolver(
+ new Map([...serverConfigs].map(([id, config]) => [id, config.providers])),
+ );
expect(resolve(makeThread(environmentId, "ghost"))).toBeNull();
expect(resolve(makeThread(environmentId, "ghost"))).toBeNull();
});
diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts
index cd07f636820c..45d281162b77 100644
--- a/apps/mobile/src/features/threads/thread-provider-instance.ts
+++ b/apps/mobile/src/features/threads/thread-provider-instance.ts
@@ -6,8 +6,9 @@ import {
resolveProviderInstanceDisplayName,
shouldShowInstanceBadge,
} from "@t3tools/client-runtime/state/provider-instance-display";
-import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts";
+import type { EnvironmentId, ProviderDriverKind } from "@t3tools/contracts";
+import type { ThreadListProvider } from "../../state/thread-list-environments";
/** What a thread row needs to draw the provider glyph and its account badge. */
export interface ThreadRowProviderInstance {
readonly driverKind: ProviderDriverKind;
@@ -22,11 +23,11 @@ export interface ThreadRowProviderInstance {
* names a different account on every server.
*/
export function resolveThreadProviderInstance(
- serverConfigs: ReadonlyMap,
+ providers: ReadonlyArray | undefined,
thread: EnvironmentThreadShell,
): ThreadRowProviderInstance | null {
- const providers = serverConfigs.get(thread.environmentId)?.providers ?? [];
- const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
+ if (providers === undefined) return null;
+ const instanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
const snapshot = providers.find((provider) => provider.instanceId === instanceId);
if (!snapshot) return null;
const entry = {
@@ -48,27 +49,33 @@ export function resolveThreadProviderInstance(
* objects. `resolveThreadProviderInstance` builds a fresh object per call,
* which breaks the memoized row's props comparison on every parent render —
* the result only depends on (environment, instance id), so one cache per
- * server-config generation keeps each row's `providerInstance` prop stable
+ * provider-list generation keeps each row's `providerInstance` prop stable
* until the instance behind the row actually changes.
*/
export function createThreadRowProviderInstanceResolver(
- serverConfigs: ReadonlyMap,
+ providersByEnvironmentId: ReadonlyMap>,
): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null {
const cache = new Map();
return (thread) => {
- const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
+ const instanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
const cacheKey = `${thread.environmentId}|${instanceId ?? ""}`;
const cached = cache.get(cacheKey);
if (cached !== undefined) return cached;
- const resolved = resolveThreadProviderInstance(serverConfigs, thread);
+ const resolved = resolveThreadProviderInstance(
+ providersByEnvironmentId.get(thread.environmentId),
+ thread,
+ );
cache.set(cacheKey, resolved);
return resolved;
};
}
-/** List-scoped wrapper: one cache per server-config generation. */
+/** List-scoped wrapper: one cache per provider-list generation. */
export function useThreadRowProviderInstanceResolver(
- serverConfigs: ReadonlyMap,
+ providersByEnvironmentId: ReadonlyMap>,
): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null {
- return useMemo(() => createThreadRowProviderInstanceResolver(serverConfigs), [serverConfigs]);
+ return useMemo(
+ () => createThreadRowProviderInstanceResolver(providersByEnvironmentId),
+ [providersByEnvironmentId],
+ );
}
diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts
index 041f8b9de010..7b13b0141edb 100644
--- a/apps/mobile/src/features/threads/thread-settings-options.test.ts
+++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts
@@ -1,7 +1,7 @@
import type { ProviderOptionDescriptor } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
-import { selectableChoices } from "./thread-settings-options";
+import { runtimeModeChoicesForSupportedModes, selectableChoices } from "./thread-settings-options";
const effortDescriptor: Extract = {
id: "effort",
@@ -27,3 +27,9 @@ describe("selectableChoices", () => {
]);
});
});
+
+describe("runtimeModeChoicesForSupportedModes", () => {
+ it("keeps controls usable when forward-compatible decoding removes every advertised mode", () => {
+ expect(runtimeModeChoicesForSupportedModes([])).toHaveLength(4);
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts
index b678154f83bb..0aa375704290 100644
--- a/apps/mobile/src/features/threads/thread-settings-options.ts
+++ b/apps/mobile/src/features/threads/thread-settings-options.ts
@@ -36,6 +36,23 @@ export const RUNTIME_MODE_CHOICES: ReadonlyArray<{
},
];
+export function runtimeModeChoicesForSupportedModes(
+ supportedRuntimeModes: ReadonlyArray | undefined,
+) {
+ return supportedRuntimeModes && supportedRuntimeModes.length > 0
+ ? RUNTIME_MODE_CHOICES.filter((choice) => supportedRuntimeModes.includes(choice.mode))
+ : RUNTIME_MODE_CHOICES;
+}
+
+export function compatibleRuntimeModeForChoices(
+ runtimeMode: RuntimeMode,
+ choices: ReadonlyArray<{ readonly mode: RuntimeMode }>,
+): RuntimeMode {
+ return choices.some((choice) => choice.mode === runtimeMode)
+ ? runtimeMode
+ : (choices[0]?.mode ?? runtimeMode);
+}
+
export function selectableChoices(
descriptor: Extract,
) {
diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx
new file mode 100644
index 000000000000..0c51c6195b75
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx
@@ -0,0 +1,235 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useIsFocused, useNavigation } from "@react-navigation/native";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display";
+import {
+ isActiveSubagentStatus,
+ isTerminalSubagentStatus,
+} from "@t3tools/client-runtime/state/subagentRuntime";
+import type {
+ EnvironmentId,
+ OrchestrationV2Subagent,
+ OrchestrationV2TurnItem,
+} from "@t3tools/contracts";
+import { useEffect, useState } from "react";
+import { AppState, Pressable, View, type ColorValue } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { ProviderIcon } from "../../components/ProviderIcon";
+import { cn } from "../../lib/cn";
+import type { ThreadFeedActivity } from "../../lib/threadActivity";
+import { serverEnvironment } from "../../state/server";
+import { environmentThreadDetails } from "../../state/threads";
+import { SubagentStatusDot } from "./SubagentStatusDot";
+import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation";
+import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
+import { WorkLogBlock } from "./work-log-layout";
+
+type SubagentItem = Extract;
+type AgentTiming = Pick;
+
+function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray }) {
+ const focused = useIsFocused();
+ const live = agents.some((agent) => isActiveSubagentStatus(agent.status));
+ const [nowMs, setNowMs] = useState(() => Date.now());
+ const [appActive, setAppActive] = useState(() => AppState.currentState === "active");
+ useEffect(() => {
+ const subscription = AppState.addEventListener("change", (state) =>
+ setAppActive(state === "active"),
+ );
+ return () => subscription.remove();
+ }, []);
+ useEffect(() => {
+ if (!live || !focused || !appActive) return;
+ const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
+ return () => clearInterval(intervalId);
+ }, [appActive, focused, live]);
+ const elapsed = subagentCardElapsed(agents, nowMs);
+ return elapsed ? (
+ {elapsed}
+ ) : null;
+}
+
+function SubagentAvatar(props: {
+ readonly item: SubagentItem;
+ readonly iconUrl?: string | null | undefined;
+ readonly status?: OrchestrationV2Subagent["status"];
+}) {
+ return (
+
+
+ {props.status ? (
+
+ ) : null}
+
+ );
+}
+
+export function ThreadSubagentGroup(props: {
+ readonly activities: ReadonlyArray;
+ readonly environmentId: EnvironmentId;
+ readonly anchorKey: string;
+ readonly expandedRows: Readonly>;
+ readonly iconSubtleColor: ColorValue;
+ readonly onToggleRow: (rowId: string, anchorKey: string) => void;
+}) {
+ const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
+ const navigation = useNavigation();
+ const members = props.activities.flatMap(({ projectedItem }) =>
+ projectedItem.item.type === "subagent" ? [projectedItem.item] : [],
+ );
+ const liveAgents = useAtomValue(
+ environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, members[0]!.threadId)),
+ (thread) => thread?.projection.subagents,
+ );
+ const agents = members.map((item) => {
+ const live = liveAgents?.find((agent) => agent.id === item.subagentId);
+ return {
+ ...item,
+ item,
+ status: live?.status ?? item.status,
+ startedAt: live?.startedAt ?? item.startedAt,
+ completedAt: live?.completedAt ?? item.completedAt,
+ result: live?.result ?? item.result,
+ progress: live?.progress ?? item.progress,
+ };
+ });
+ const grouped = agents.length > 1;
+ const label = `${agents.length} subagents`;
+ const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status));
+ const expanded = props.expandedRows[props.anchorKey] ?? false;
+ const iconUrl = (item: SubagentItem) =>
+ config?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl;
+ return (
+
+ {grouped ? (
+ props.onToggleRow(props.anchorKey, props.anchorKey)}
+ className="min-h-14 flex-row items-center gap-3 rounded-lg py-2 active:bg-subtle"
+ >
+
+ {agents.slice(0, 3).map((agent, index) => (
+
+
+
+ ))}
+ {agents.length > 3 ? (
+
+ +{agents.length - 3}
+
+ ) : null}
+
+
+
+ {label}
+
+ isActiveSubagentStatus(agent.status))
+ ? "text-adaptive-sky-600-400"
+ : agents.some((agent) => agent.status === "failed") &&
+ "text-adaptive-rose-600-400",
+ )}
+ >
+ {summary}
+
+
+
+
+
+ ) : null}
+ {!grouped || expanded ? (
+
+ {agents.map((agent) => {
+ const presentation = resolveSubagentRowPresentation(agent);
+ const detail = subagentCardDetail(
+ isTerminalSubagentStatus(agent.status)
+ ? agent.result?.trim() || agent.progress || null
+ : agent.progress?.trim() || agent.result,
+ );
+ const threadId = agent.childThreadId;
+ return (
+ {
+ if (threadId !== null)
+ navigation.navigate("Thread", {
+ environmentId: String(props.environmentId),
+ threadId: String(threadId),
+ });
+ }}
+ className="min-h-14 flex-row items-center gap-3 rounded-lg px-2 py-2 active:bg-subtle"
+ >
+
+
+
+
+ {presentation.title}
+
+ {detail && agent.status !== "completed" ? (
+
+ {presentation.statusLabel}
+
+ ) : null}
+
+
+ {detail ?? presentation.statusLabel}
+
+
+
+ {threadId !== null ? (
+
+ ) : null}
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index a2053a2a67b3..826b9205ac75 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -1,3 +1,12 @@
+import { SubagentStatusDot } from "./SubagentStatusDot";
+import { ThreadSubagentGroup } from "./thread-subagent-group";
+import {
+ WorkLogLabel,
+ WorkLogBlock,
+ WorkLogRows,
+ WorkLogIconSlot,
+ WorkLogPressable,
+} from "./work-log-layout";
import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
import {
getQuestionAnswerPreview,
@@ -9,7 +18,7 @@ import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import type { LegendListRef } from "@legendapp/list/react-native";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
-import { useIsFocused } from "@react-navigation/native";
+import { useIsFocused, useNavigation } from "@react-navigation/native";
import {
memo,
useCallback,
@@ -55,6 +64,7 @@ import {
workEntryViewedImagePath,
} from "@t3tools/client-runtime/work-log/presentation";
import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
+import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
import Animated, {
cancelAnimation,
@@ -361,6 +371,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
return { ios: "eye", android: "visibility" };
case "globe":
return { ios: "globe", android: "public" };
+ case "search":
+ return "magnifyingglass";
case "hammer":
return { ios: "hammer", android: "construction" };
case "lock":
@@ -401,18 +413,26 @@ function workLogRowsHeight(
return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP;
}
-export function collapsedWorkLogHeight(activities: ReadonlyArray): number {
+export function collapsedWorkLogHeight(
+ activities: ReadonlyArray,
+ continues = false,
+): number {
+ const bottomMargin = continues ? 0 : WORK_LOG_BOTTOM_MARGIN;
if (activities.length === 0) {
return 0;
}
+ if (activities[0]?.projectedItem.item.type === "subagent") {
+ return bottomMargin + WORK_ROW_HEIGHT;
+ }
const height = workLogRowsHeight(activities);
return (
- WORK_LOG_BOTTOM_MARGIN +
+ bottomMargin +
(activities[0]?.groupedToolDetail ? Math.min(height, WORK_GROUP_MAX_HEIGHT) : height)
);
}
interface ThreadWorkLogProps {
+ readonly continuesWorkLog?: boolean | undefined;
readonly activities: ReadonlyArray;
readonly anchorKey: string;
readonly environmentId: EnvironmentId;
@@ -427,6 +447,7 @@ interface ThreadWorkLogProps {
readonly onCopyRow: (rowId: string, value: string) => void;
readonly onToggleRow: (rowId: string, anchorKey: string) => void;
readonly renderImage: MarkdownImageRenderer;
+ readonly renderReasoning: (text: string) => ReactNode;
}
export function ThreadWorkLog(props: ThreadWorkLogProps) {
@@ -443,6 +464,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
onCopyRow={props.onCopyRow}
onToggleRow={props.onToggleRow}
renderImage={props.renderImage}
+ renderReasoning={props.renderReasoning}
themeAppearance={props.themeAppearance}
/>
),
@@ -455,6 +477,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
props.onCopyRow,
props.onToggleRow,
props.renderImage,
+ props.renderReasoning,
props.themeAppearance,
],
);
@@ -463,8 +486,25 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
return null;
}
+ if (props.activities[0]?.projectedItem.item.type === "subagent") {
+ return ;
+ }
+
+ if (
+ props.activities[0]?.groupedToolDetail &&
+ props.activities.every((row) => row.projectedItem.item.type === "reasoning")
+ ) {
+ return (
+
+ {props.activities.map((row) => (
+ {props.renderReasoning(row.workEntry.detail ?? "")}
+ ))}
+
+ );
+ }
+
return (
-
+
{props.activities[0]?.groupedToolDetail ? (
) : (
- {props.activities.map(renderRow)}
+ {props.activities.map(renderRow)}
)}
-
+
);
}
@@ -733,6 +773,7 @@ function workLogRowKey(row: ThreadFeedActivity): string {
const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
props: Omit<
ThreadWorkLogProps,
+ | "continuesWorkLog"
| "activities"
| "copiedRowId"
| "edgeFadeColor"
@@ -746,8 +787,81 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
},
) {
const { row, expanded } = props;
- const canExpand = row.canExpand;
- const fullDetail = expanded ? row.getFullDetail() : null;
+ const navigation = useNavigation();
+ const failureItem = row.projectedItem.item;
+ if (failureItem.type === "error" && failureItem.status === "failed") {
+ const warning = failureItem.failure.class === "usage_limit";
+ const timestamp = new Date(row.createdAt);
+ const resetAt = failureItem.failure.resetAt;
+ const resetTime = resetAt
+ ? new Date(resetAt).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ })
+ : null;
+ const label = warning
+ ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
+ : row.summary;
+ return (
+ props.onCopyRow(row.id, row.getCopyText())}
+ >
+
+
+
+
+
+
+ {label}
+
+ {props.copied ? (
+
+ Copied
+
+ ) : null}
+
+ {timestamp.toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ })}
+
+
+ {!warning ? (
+
+ {failureItem.failure.message}
+
+ ) : null}
+
+
+ );
+ }
+ // A subagent's notification opens its thread, like the subagent's own row.
+ const notifiedSubagentThreadId =
+ row.projectedItem.item.type === "notification"
+ ? notificationChildThreadId(row.projectedItem.item.source)
+ : undefined;
+ const canExpand = row.canExpand && notifiedSubagentThreadId === undefined;
+ const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null;
+ const fullDetail = expanded && !reasoning ? row.getFullDetail() : null;
const viewedImagePath = workEntryViewedImagePath(row.workEntry);
const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
const previewText = workEntryRowLabel(row.workEntry);
@@ -756,10 +870,16 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
: null;
const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
const displayText = workEntryRowLabel(row.workEntry, expanded);
- const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
+ const isSystemNotice = row.projectedItem.item.type === "system_notice";
+ const isUsageLimit =
+ row.projectedItem.item.type === "error" &&
+ row.projectedItem.item.failure.class === "usage_limit" &&
+ row.projectedItem.item.status !== "completed";
+ const iconIsDestructive =
+ !isSystemNotice && !isUsageLimit && (row.icon === "alert" || row.icon === "warning");
const failed = row.status === "failure";
const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon;
- const icon = toolPresentation?.icon ?? workRowSymbolName(row.icon);
+ const icon = reasoning ? "brain" : (toolPresentation?.icon ?? workRowSymbolName(row.icon));
return (
- {
+ if (notifiedSubagentThreadId !== undefined) {
+ navigation.navigate("Thread", {
+ environmentId: String(props.environmentId),
+ threadId: String(notifiedSubagentThreadId),
+ });
+ return;
+ }
if (canExpand) {
void Haptics.selectionAsync();
props.onToggleRow(row.id, props.anchorKey);
}
}}
onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
- className="rounded-md px-0.5 py-0 active:bg-subtle"
>
-
- {row.live && !expanded ? (
-
- ) : (
- <>
-
- {toolIcon ? (
-
- ) : (
-
+ ) : (
+ <>
+
+ {toolIcon ? (
+
+ ) : (
+
- )}
-
-
- {displayText}
- {answerPreview ? (
- {` ${answerPreview}`}
- ) : null}
-
- >
- )}
-
-
- {props.copied ? (
-
- Copied
-
- ) : null}
- {failed && toolIcon !== undefined ? (
-
-
-
- ) : null}
-
- {canExpand ? (
-
+ )}
+
+
+ {isSystemNotice ? row.summary : displayText}
+ {answerPreview ? (
+ {` ${answerPreview}`}
) : null}
+
+ >
+ )}
+
+
+ {props.copied ? (
+
+ Copied
+
+ ) : null}
+ {failed && toolIcon !== undefined ? (
+
+
+ ) : null}
+
+ {canExpand ? (
+
+ ) : null}
-
+
- {expanded && (fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? (
+ {expanded && (reasoning || fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? (
{row.workEntry.questionAnswer ? (
-
- {fullDetail}
-
+ {reasoning ? (
+ props.renderReasoning(reasoning.text)
+ ) : (
+
+ {fullDetail}
+
+ )}
) : null}
@@ -940,19 +1069,17 @@ export function ThreadWorkGroupToggle(props: {
: toolGroupSummarySymbolName(props.summaryKind));
return (
-
-
+ {
void Haptics.selectionAsync();
props.onToggle();
}}
- className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
- style={{ minHeight: props.rowSizing.estimatedRowHeight }}
+ rowSizing={props.rowSizing}
>
{props.shimmer ? (
) : (
<>
-
+
-
-
- {props.summary}
-
+
+ {props.summary}
>
)}
-
-
+
+
);
}
-const AGENT_SPAWN_TONE_DOT_CLASS = {
- working: "bg-adaptive-sky-600-400",
- completed: "bg-adaptive-emerald-600-400",
- failed: "bg-adaptive-rose-600-400",
- stopped: "bg-foreground-muted",
-} as const satisfies Record;
-
/**
* A batch of spawned subagents. The status line updates in place as members
* report progress; expanding lists each member. Text nodes carry keys tied to
@@ -1060,12 +1174,7 @@ export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: {
{summary.title}
-
+
{working ? (
(
-
+
{member.title}
@@ -1152,85 +1256,6 @@ export function ThreadThinkingRow(props: {
);
}
-/**
- * A provider's thinking trace. Collapsed by default: reasoning is context for
- * the answer, not the answer. `expanded` lives on the feed so it survives row
- * recycling; `children` is the trace body and only mounts while open.
- */
-export function ThreadReasoningRow(props: {
- readonly rowSizing: ReturnType;
- readonly iconSubtleColor: ColorValue;
- readonly expanded: boolean;
- readonly label: string;
- readonly streaming: boolean;
- readonly onToggle: () => void;
- readonly children: ReactNode;
-}) {
- return (
-
- {
- void Haptics.selectionAsync();
- props.onToggle();
- }}
- className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
- style={{ minHeight: props.rowSizing.estimatedRowHeight }}
- >
- {props.streaming ? (
-
- ) : (
- <>
-
-
-
-
- {props.label}
-
- >
- )}
-
-
- {props.expanded ? (
-
-
- {props.children}
-
-
- ) : null}
-
- );
-}
-
function ToolActivityIconView(props: {
readonly environmentId: EnvironmentId;
readonly icon?: ToolActivityIcon;
@@ -1348,6 +1373,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
return { ios: "eye", android: "visibility" };
case "edit":
return { ios: "square.and.pencil", android: "edit" };
+ case "thread-create":
+ return { ios: "bubble.left", android: "chat" };
case "command":
return { ios: "terminal", android: "terminal" };
case "device":
@@ -1359,6 +1386,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
return "magnifyingglass";
case "other":
return { ios: "wrench", android: "build" };
+ case "reasoning":
+ return { ios: "brain", android: "psychology" };
case "agent-tool":
return { ios: "sparkles", android: "auto_awesome" };
case "tone-tool":
diff --git a/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts b/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts
new file mode 100644
index 000000000000..a8f9343085b8
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts
@@ -0,0 +1,26 @@
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+
+import { buildThreadActivityFileParams } from "./threadActivityFileNavigation";
+
+describe("thread activity file navigation", () => {
+ it("keeps inherited activity file links on the currently selected thread", () => {
+ const sourceThreadId = ThreadId.make("source-thread");
+ const currentThreadId = ThreadId.make("current-thread");
+
+ const params = buildThreadActivityFileParams({
+ environmentId: EnvironmentId.make("environment"),
+ currentThreadId,
+ activitySourceThreadId: sourceThreadId,
+ relativePath: "apps/mobile/src/index.ts",
+ line: 12,
+ });
+
+ expect(params).toEqual({
+ environmentId: "environment",
+ threadId: "current-thread",
+ path: ["apps", "mobile", "src", "index.ts"],
+ line: "12",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadActivityFileNavigation.ts b/apps/mobile/src/features/threads/threadActivityFileNavigation.ts
new file mode 100644
index 000000000000..21dfc44c0268
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadActivityFileNavigation.ts
@@ -0,0 +1,22 @@
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+
+interface ThreadActivityFileContext {
+ readonly environmentId: EnvironmentId;
+ readonly currentThreadId: ThreadId;
+ readonly activitySourceThreadId: ThreadId;
+ readonly relativePath: string;
+ readonly line?: number | null;
+}
+
+export function buildThreadActivityFileParams(input: ThreadActivityFileContext) {
+ // Activity provenance may come from a parent thread, but file routes stay scoped
+ // to the thread whose workspace is currently selected.
+ return {
+ environmentId: String(input.environmentId),
+ threadId: String(input.currentThreadId),
+ path: input.relativePath.split("/").filter((segment) => segment.length > 0),
+ ...(Number.isFinite(input.line) && Number(input.line) > 0
+ ? { line: String(Math.floor(Number(input.line))) }
+ : {}),
+ };
+}
diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts
new file mode 100644
index 000000000000..bf095f541873
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
+
+const base = {
+ title: null,
+ prompt: "Audit the timestamps",
+ status: "running" as const,
+ result: null,
+ childThreadId: "thread-child" as never,
+};
+
+describe("resolveSubagentRowPresentation", () => {
+ it("leads with progress while the agent is working", () => {
+ const row = resolveSubagentRowPresentation({
+ ...base,
+ progress: "Reading files",
+ result: "stale result",
+ });
+
+ expect(row.detail).toBe("Reading files");
+ expect(row.tone).toBe("working");
+ expect(row.live).toBe(true);
+ });
+
+ it("leads with the result once the agent has settled", () => {
+ const row = resolveSubagentRowPresentation({
+ ...base,
+ status: "completed",
+ progress: "Reading files",
+ result: "Found two\n problems",
+ });
+
+ expect(row.detail).toBe("Found two problems");
+ expect(row.tone).toBe("completed");
+ expect(row.statusLabel).toBe("Completed");
+ });
+
+ it("shows a failure's text, since that is where the error lands", () => {
+ const row = resolveSubagentRowPresentation({ ...base, status: "failed", result: "Timed out" });
+
+ expect(row.detail).toBe("Timed out");
+ expect(row.tone).toBe("failed");
+ });
+
+ it("falls back to a trimmed prompt when the agent has no title", () => {
+ const long = resolveSubagentRowPresentation({ ...base, prompt: "x".repeat(200) });
+ const titled = resolveSubagentRowPresentation({ ...base, title: "Subagent: /root/my_worker" });
+
+ expect(long.title).toHaveLength(80);
+ expect(long.title.endsWith("...")).toBe(true);
+ expect(titled.title).toBe("My Worker");
+ });
+
+ it("only offers a thread to open when the agent has one", () => {
+ expect(resolveSubagentRowPresentation(base).canOpenThread).toBe(true);
+ expect(resolveSubagentRowPresentation({ ...base, childThreadId: null }).canOpenThread).toBe(
+ false,
+ );
+ });
+
+ it("uses the status label when there is nothing to report yet", () => {
+ const row = resolveSubagentRowPresentation({ ...base, status: "pending" });
+
+ expect(row.detail).toBeNull();
+ expect(row.statusLabel).toBe("Working");
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts
new file mode 100644
index 000000000000..d1b85b896470
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts
@@ -0,0 +1,79 @@
+import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
+import {
+ isActiveSubagentStatus,
+ isTerminalSubagentStatus,
+} from "@t3tools/client-runtime/state/subagentRuntime";
+import type { OrchestrationV2Subagent } from "@t3tools/contracts";
+
+const PROMPT_TITLE_LIMIT = 80;
+
+export type SubagentRowTone = "working" | "completed" | "failed" | "stopped";
+
+export interface SubagentRowPresentation {
+ readonly title: string;
+ /** Live agents lead with progress; settled ones lead with what came out. */
+ readonly detail: string | null;
+ readonly statusLabel: string;
+ readonly tone: SubagentRowTone;
+ readonly live: boolean;
+ /** Provider-native tasks have no thread of their own to open. */
+ readonly canOpenThread: boolean;
+}
+
+function rowTitle(subagent: Pick): string {
+ const title = subagent.title?.trim();
+ if (title) return formatSubagentDisplayTitle(title);
+ const prompt = subagent.prompt.trim();
+ if (prompt.length === 0) return "Subagent";
+ return prompt.length > PROMPT_TITLE_LIMIT
+ ? `${prompt.slice(0, PROMPT_TITLE_LIMIT - 3)}...`
+ : prompt;
+}
+
+function rowTone(status: OrchestrationV2Subagent["status"]): SubagentRowTone {
+ if (isActiveSubagentStatus(status)) return "working";
+ if (status === "completed") return "completed";
+ if (status === "failed") return "failed";
+ return "stopped";
+}
+
+function rowStatusLabel(status: OrchestrationV2Subagent["status"]): string {
+ switch (status) {
+ case "pending":
+ case "running":
+ return "Working";
+ case "waiting":
+ return "Waiting";
+ case "idle":
+ return "Idle";
+ case "completed":
+ return "Completed";
+ case "failed":
+ return "Failed";
+ case "cancelled":
+ return "Cancelled";
+ case "interrupted":
+ return "Interrupted";
+ }
+}
+
+export function resolveSubagentRowPresentation(
+ subagent: Pick<
+ OrchestrationV2Subagent,
+ "title" | "prompt" | "status" | "progress" | "result" | "childThreadId"
+ >,
+): SubagentRowPresentation {
+ const live = isActiveSubagentStatus(subagent.status);
+ const progress = subagent.progress?.trim() ?? "";
+ const result = subagent.result?.trim() ?? "";
+ const settled = isTerminalSubagentStatus(subagent.status);
+ const detail = settled ? result || progress : progress || result;
+ return {
+ title: rowTitle(subagent),
+ detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null,
+ statusLabel: rowStatusLabel(subagent.status),
+ tone: rowTone(subagent.status),
+ live,
+ canOpenThread: subagent.childThreadId !== null,
+ };
+}
diff --git a/apps/mobile/src/features/threads/threadForkNavigation.test.ts b/apps/mobile/src/features/threads/threadForkNavigation.test.ts
new file mode 100644
index 000000000000..a56cb3da2e36
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadForkNavigation.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { waitForThreadShellReady } from "./threadForkNavigation";
+
+describe("waitForThreadShellReady", () => {
+ it("returns true when the forked thread shell arrives before the deadline", async () => {
+ let elapsedMs = 0;
+
+ const ready = await waitForThreadShellReady({
+ read: () => elapsedMs >= 80,
+ timeoutMs: 120,
+ pollIntervalMs: 40,
+ now: () => elapsedMs,
+ delay: async (durationMs) => {
+ elapsedMs += durationMs;
+ },
+ });
+
+ expect(ready).toBe(true);
+ });
+
+ it("returns false instead of navigating when the shell never arrives", async () => {
+ let elapsedMs = 0;
+
+ const ready = await waitForThreadShellReady({
+ read: () => false,
+ timeoutMs: 80,
+ pollIntervalMs: 40,
+ now: () => elapsedMs,
+ delay: async (durationMs) => {
+ elapsedMs += durationMs;
+ },
+ });
+
+ expect(ready).toBe(false);
+ expect(elapsedMs).toBe(80);
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadForkNavigation.ts b/apps/mobile/src/features/threads/threadForkNavigation.ts
new file mode 100644
index 000000000000..97ebf9c97bc0
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadForkNavigation.ts
@@ -0,0 +1,26 @@
+const DEFAULT_TIMEOUT_MS = 2_000;
+const DEFAULT_POLL_INTERVAL_MS = 40;
+
+export async function waitForThreadShellReady(input: {
+ readonly read: () => boolean;
+ readonly timeoutMs?: number;
+ readonly pollIntervalMs?: number;
+ readonly now?: () => number;
+ readonly delay?: (durationMs: number) => Promise;
+}): Promise {
+ const now = input.now ?? Date.now;
+ const delay =
+ input.delay ??
+ ((durationMs: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, durationMs);
+ }));
+ const deadline = now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+ const pollIntervalMs = input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
+
+ while (!input.read() && now() < deadline) {
+ await delay(Math.min(pollIntervalMs, deadline - now()));
+ }
+
+ return input.read();
+}
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index 4bbcf2233a42..166baa9d2e46 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -17,12 +17,13 @@ import {
MessageId,
ProjectId,
ProviderInstanceId,
+ RunId,
ThreadId,
- TurnId,
} from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
+import { makeThreadShellFixture } from "../../test-fixtures";
import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
import {
buildThreadListV2Items,
@@ -43,31 +44,14 @@ const environmentId = EnvironmentId.make("environment-1");
function makeThread(
input: Partial & Pick,
): EnvironmentThreadShell {
- return {
+ return makeThreadShellFixture({
environmentId,
- projectId: ProjectId.make("project-1"),
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- pullRequests: [],
- latestTurn: null,
- createdAt: "2026-06-01T00:00:00.000Z",
- updatedAt: "2026-06-01T00:00:00.000Z",
- archivedAt: null,
- settledOverride: null,
- settledAt: null,
- session: null,
- latestUserMessageAt: null,
- hasPendingApprovals: false,
- hasPendingUserInput: false,
- hasActionableProposedPlan: false,
...input,
- };
+ });
}
const NOW = "2026-06-02T00:00:00.000Z";
+
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
@@ -124,18 +108,45 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
});
describe("resolveThreadListV2Status", () => {
- it("prioritizes approval over a running session", () => {
+ it("distinguishes usage limits from ordinary failures and clears the label after recovery", () => {
+ const thread = makeThread({
+ id: ThreadId.make("limited"),
+ title: "Limited",
+ runtime: {
+ status: "failed",
+ activeRunId: null,
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ providerName: "Codex",
+ lastError: "Plan limit reached",
+ lastErrorClass: "usage_limit",
+ updatedAt: NOW,
+ },
+ });
+ expect(resolveThreadListV2Status(thread)).toBe("limited");
+ expect(
+ resolveThreadListV2Status({
+ ...thread,
+ runtime: { ...thread.runtime!, lastErrorClass: null },
+ }),
+ ).toBe("failed");
+ expect(
+ resolveThreadListV2Status({
+ ...thread,
+ runtime: { ...thread.runtime!, status: "completed" },
+ }),
+ ).toBe("ready");
+ });
+
+ it("prioritizes approval over a running runtime", () => {
const thread = makeThread({
id: ThreadId.make("t"),
title: "t",
hasPendingApprovals: true,
- session: {
- threadId: ThreadId.make("t"),
+ runtime: {
status: "running",
+ activeRunId: RunId.make("run-t"),
providerName: "Codex",
providerInstanceId: ProviderInstanceId.make("codex"),
- runtimeMode: "full-access",
- activeTurnId: null,
lastError: null,
updatedAt: NOW,
},
@@ -143,6 +154,28 @@ describe("resolveThreadListV2Status", () => {
expect(resolveThreadListV2Status(thread)).toBe("approval");
});
+ it("reports waiting when presentation parks runtime idle for background tasks", () => {
+ expect(
+ resolveThreadListV2Status(
+ makeThread({
+ id: ThreadId.make("t"),
+ title: "t",
+ pendingBackgroundTasks: [
+ { taskId: "bg-1", description: "Run Codex review", kind: "command" },
+ ],
+ runtime: {
+ status: "idle",
+ activeRunId: null,
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ providerName: "Codex",
+ lastError: null,
+ updatedAt: NOW,
+ },
+ }),
+ ),
+ ).toBe("waiting");
+ });
+
it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
@@ -309,19 +342,6 @@ describe("sortThreadsForListV2", () => {
]);
expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
});
-
- it("surfaces an un-settled thread at the top via its re-entry stamp", () => {
- const sorted = sortThreadsForListV2([
- {
- id: "old-unsettled",
- createdAt: "2026-06-01T08:00:00.000Z",
- unsettledAt: "2026-06-01T13:00:00.000Z",
- },
- { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" },
- { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" },
- ]);
- expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]);
- });
});
describe("getThreadListV2OrderedSection", () => {
@@ -465,6 +485,60 @@ describe("buildThreadListV2Items", () => {
expect(layout.settledCount).toBe(0);
});
+ it("hides snoozed threads and counts them — visibility parity with web", () => {
+ const layout = buildThreadListV2Items({
+ threads: [
+ makeThread({ id: ThreadId.make("active"), title: "Active" }),
+ makeThread({
+ id: ThreadId.make("snoozed"),
+ title: "Snoozed",
+ snoozedUntil: "2026-06-03T09:00:00.000Z",
+ snoozedAt: "2026-06-01T12:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.make("woken"),
+ title: "Woken",
+ // Wake time already passed: back in the active list.
+ snoozedUntil: "2026-06-01T18:00:00.000Z",
+ snoozedAt: "2026-06-01T12:00:00.000Z",
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+
+ // Same createdAt → static sort tiebreaks by id; the point is the woken
+ // thread is BACK in the card block and the snoozed one is gone.
+ expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]);
+ expect(layout.snoozedCount).toBe(1);
+ });
+
+ it("moves a settled pinned thread into the settled shelf — parity with web (#7969)", () => {
+ const layout = buildThreadListV2Items({
+ threads: [
+ makeThread({ id: ThreadId.make("active"), title: "Active" }),
+ makeThread({
+ id: ThreadId.make("pinned-settled"),
+ title: "Pinned while settled",
+ pinnedAt: "2026-06-01T12:00:00.000Z",
+ // Stale settled state (the decider clears it on pin): the pin wins.
+ settledOverride: "settled",
+ settledAt: "2026-06-01T12:00:00.000Z",
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+
+ // Since #7969 a settled thread leaves the active block even while pinned;
+ // the pin re-applies when the thread is un-settled.
+ expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]);
+ expect(layout.items.map((item) => item.pinned)).toEqual([false, false]);
+ expect(layout.settledCount).toBe(1);
+ });
+
it("snooze hides a pinned thread and wake restores it to the pinned block", () => {
const snoozedInput = {
threads: [
@@ -804,10 +878,11 @@ describe("buildThreadListV2Items", () => {
});
it("scopes the flat list to one project", () => {
+ const projectId = ProjectId.make("project-1");
const otherProjectId = ProjectId.make("project-2");
const { items } = buildThreadListV2Items({
threads: [
- makeThread({ id: ThreadId.make("included"), title: "Included" }),
+ makeThread({ id: ThreadId.make("included"), projectId, title: "Included" }),
makeThread({
id: ThreadId.make("excluded"),
projectId: otherProjectId,
@@ -815,7 +890,7 @@ describe("buildThreadListV2Items", () => {
}),
],
environmentId: null,
- projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }],
+ projectRefs: [{ environmentId, projectId }],
searchQuery: "",
now: NOW,
});
@@ -825,19 +900,21 @@ describe("buildThreadListV2Items", () => {
it("scopes the flat list to every environment member of a logical project", () => {
const remoteEnvironmentId = EnvironmentId.make("environment-remote");
+ const projectId = ProjectId.make("project-1");
const { items } = buildThreadListV2Items({
threads: [
- makeThread({ id: ThreadId.make("local"), title: "Local" }),
+ makeThread({ id: ThreadId.make("local"), projectId, title: "Local" }),
makeThread({
environmentId: remoteEnvironmentId,
id: ThreadId.make("remote"),
+ projectId,
title: "Remote",
}),
],
environmentId: null,
projectRefs: [
- { environmentId, projectId: ProjectId.make("project-1") },
- { environmentId: remoteEnvironmentId, projectId: ProjectId.make("project-1") },
+ { environmentId, projectId },
+ { environmentId: remoteEnvironmentId, projectId },
],
searchQuery: "",
now: NOW,
@@ -860,9 +937,9 @@ describe("buildThreadListV2Items settled paging", () => {
latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
// A turn adopted the message (same requestedAt): without it the
// thread reads as a queued turn start, which never settles.
- latestTurn: {
- turnId: TurnId.make(`turn-${index}`),
- state: "completed",
+ latestRun: {
+ runId: RunId.make(`run-${index}`),
+ status: "completed",
requestedAt: `2026-06-01T0${index}:00:00.000Z`,
startedAt: `2026-06-01T0${index}:00:00.000Z`,
completedAt: `2026-06-01T0${index}:10:00.000Z`,
@@ -1463,20 +1540,47 @@ describe("cross-section thread drops", () => {
});
});
+it("excludes subagents from navigation, search and ordering while retaining user forks", () => {
+ const root = makeThread({ id: ThreadId.make("root"), title: "Root" });
+ const child = makeThread({
+ id: ThreadId.make("child"),
+ title: "Child",
+ lineage: { parentThreadId: root.id, rootThreadId: root.id, relationshipToParent: "subagent" },
+ });
+ const fork = makeThread({
+ id: ThreadId.make("fork"),
+ title: "Fork",
+ lineage: { parentThreadId: root.id, rootThreadId: root.id, relationshipToParent: "fork" },
+ });
+ const threads = [root, child, fork];
+ expect(
+ buildThreadListV2Items({ threads, environmentId: null, searchQuery: "", now: NOW }).items.map(
+ (item) => item.thread.id,
+ ),
+ ).toEqual([fork.id, root.id]);
+ expect(
+ buildThreadListV2Items({ threads, environmentId: null, searchQuery: "Child", now: NOW }).items,
+ ).toEqual([]);
+ expect(
+ getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
+ (thread) => thread.id,
+ ),
+ ).toEqual([fork.id, root.id]);
+});
+
/* ─── Recycled-list equality + per-row clock scoping ─────────────────── */
const BASE_MS = Date.parse(NOW);
const isoAt = (ms: number) => new Date(ms).toISOString();
const MINUTE_MS = 60_000;
-function runningSession(threadId: string) {
+function runningRuntime() {
return {
- threadId: ThreadId.make(threadId),
status: "running" as const,
providerName: "Codex",
providerInstanceId: ProviderInstanceId.make("codex"),
runtimeMode: "full-access" as const,
- activeTurnId: null,
+ activeRunId: null,
lastError: null,
updatedAt: NOW,
};
@@ -1759,7 +1863,7 @@ describe("buildThreadListV2ListItems clock scoping", () => {
id: ThreadId.make("tick-working"),
title: "tick working",
latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
- session: runningSession("tick-working"),
+ runtime: runningRuntime(),
});
const threads = buildTickThreads();
const items = buildTickList(
@@ -1823,6 +1927,27 @@ describe("thread list v2 minute tick invalidation", () => {
}
});
+ it("keeps unread completion labels stable across a minute tick", () => {
+ const unread = makeThread({
+ id: ThreadId.make("tick-unread"),
+ title: "Unread completion",
+ lastVisitedAt: isoAt(BASE_MS - 10 * MINUTE_MS),
+ latestRun: {
+ runId: RunId.make("tick-unread-run"),
+ status: "completed",
+ requestedAt: isoAt(BASE_MS - 6 * MINUTE_MS),
+ startedAt: isoAt(BASE_MS - 6 * MINUTE_MS),
+ completedAt: isoAt(BASE_MS - 5 * MINUTE_MS),
+ assistantMessageId: null,
+ },
+ });
+ const options = { snoozeEnvironmentIds: new Set() };
+ const first = buildTickList([unread], BASE_MS, [], options)[0]!;
+ const next = buildTickList([unread], BASE_MS + MINUTE_MS, [], options)[0]!;
+ expect(first.type === "v2-thread" && first.timeLabel).toBe("");
+ expect(threadListV2ListItemsAreEqual(first, next)).toBe(true);
+ });
+
it("keeps hour-granularity rows stable across a minute tick when they carry no snooze menu", () => {
vi.useFakeTimers();
try {
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index 8c4dd0b0f097..f0b2459301ae 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -9,6 +9,7 @@ import {
} from "@t3tools/client-runtime/state/thread-settled";
import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import {
sortActiveThreadsByOrderKey,
@@ -18,6 +19,7 @@ import {
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import type { ThreadListProvider } from "../../state/thread-list-environments";
import type { ThreadMoveAvailability } from "./threadOrder";
import { relativeTime } from "../../lib/time";
@@ -31,15 +33,45 @@ import {
export { snoozeWakeLabel };
+/**
+ * Provider drivers for a row's trailing icon stack, back to front. Instances
+ * missing from the environment's config are skipped, and an unresolved
+ * current provider yields nothing so the row never draws a stale stack.
+ */
+export function resolveThreadListV2ProviderDrivers(
+ thread: Pick,
+ providers: ReadonlyArray | undefined,
+): ReadonlyArray {
+ if (providers === undefined) return [];
+ const stack = resolveThreadProviderStack(thread);
+ const drivers = stack.flatMap((instanceId) => {
+ const driver = providers.find((provider) => provider.instanceId === instanceId)?.driver;
+ return driver === undefined ? [] : [driver];
+ });
+ const currentDriver = providers.find(
+ (provider) => provider.instanceId === stack[stack.length - 1],
+ )?.driver;
+ return currentDriver === undefined ? [] : drivers;
+}
+
/**
* Thread List v2 model, ported from the web sidebar v2
* (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx).
*
- * Four visual states, three colors: color is reserved for "act now"
- * (approval), "in motion" (working), and "broken" (failed). Ready is the
- * unlabeled resting state.
+ * Six visual states. Color distinguishes approval, input, active work, and
+ * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent
+ * parked on open background tasks, grey like working rather than a false Done.
+ * The orchestrator v2 presentation bridge parks runtime at idle when the
+ * post-settlement background roster is nonempty.
*/
-export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
+export type ThreadListV2Status =
+ | "approval"
+ | "input"
+ | "working"
+ | "waiting"
+ | "failed"
+ | "limited"
+ | "ready";
export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
export function resolveThreadListV2SnoozeMenuSelection(input: {
@@ -100,7 +132,7 @@ export function resolveThreadListV2SwipeActions(input: {
export function resolveThreadListV2SnoozeGateExpiryMs(
thread: Pick<
EnvironmentThreadShell,
- "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session"
+ "hasPendingApprovals" | "hasPendingUserInput" | "latestRun" | "latestUserMessageAt" | "runtime"
>,
options: { readonly now: string },
): number | null {
@@ -117,8 +149,29 @@ export function resolveThreadListV2SnoozeGateExpiryMs(
export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
+/**
+ * Completed-but-not-yet-seen, mirroring the web sidebar's
+ * hasUnseenCompletion. The visited watermark is server state
+ * (thread.lastVisitedAt), so the marker agrees across web and mobile.
+ * Never-visited threads count as read — a fresh environment must not light
+ * up its whole history — and pre-tracking servers (field absent) never
+ * report unread.
+ */
+export function threadHasUnseenCompletion(
+ thread: Pick,
+): boolean {
+ const completedAt = thread.latestRun?.completedAt;
+ if (!completedAt) return false;
+ const completedAtMs = Date.parse(completedAt);
+ if (Number.isNaN(completedAtMs)) return false;
+ if (!thread.lastVisitedAt) return false;
+ const lastVisitedAtMs = Date.parse(thread.lastVisitedAt);
+ if (Number.isNaN(lastVisitedAtMs)) return true;
+ return completedAtMs > lastVisitedAtMs;
+}
+
export function resolveThreadListV2Status(
- thread: Pick,
+ thread: Pick,
): ThreadListV2Status {
if (thread.hasPendingApprovals) {
return "approval";
@@ -126,11 +179,17 @@ export function resolveThreadListV2Status(
if (thread.hasPendingUserInput) {
return "input";
}
- if (thread.session?.status === "running" || thread.session?.status === "starting") {
+ if (
+ thread.runtime !== null &&
+ ["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status)
+ ) {
return "working";
}
- if (thread.session?.status === "error") {
- return "failed";
+ if (thread.runtime?.status === "idle") {
+ return "waiting";
+ }
+ if (thread.runtime?.status === "failed") {
+ return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed";
}
return "ready";
}
@@ -167,7 +226,8 @@ export function getThreadListV2OrderedSection(input: {
readonly queuedThreadKeys?: ReadonlySet;
}): EnvironmentThreadShell[] {
const threads = input.threads.filter((thread) => {
- if (thread.archivedAt !== null) return false;
+ if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent")
+ return false;
if (
(input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
thread.settledOverride === "settled" &&
@@ -367,7 +427,11 @@ function resolveThreadListV2ItemTimeLabel(
): string {
const { thread, variant, snoozed } = item;
if (showSnoozeWakeLabel) return "";
- if (variant === "card" && resolveThreadListV2Status(thread) !== "ready") return "";
+ if (
+ variant === "card" &&
+ (resolveThreadListV2Status(thread) !== "ready" || threadHasUnseenCompletion(thread))
+ )
+ return "";
const settledTimestamp =
variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null;
return relativeTime(
@@ -550,7 +614,8 @@ export function buildThreadListV2Items(input: {
const snoozed: EnvironmentThreadShell[] = [];
let nextSnoozeWakeAt: string | null = null;
for (const thread of input.threads) {
- // Callers pass live shells. The server stamps settledOverride for the tail.
+ if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") continue;
+ // The server stamps settledOverride for the tail.
if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
continue;
diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts
new file mode 100644
index 000000000000..8a770537a9b0
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL,
+ buildCancelQueuedRunCommand,
+ resolveQueueDragBeforeRunId,
+ resolveThreadQueueRowControls,
+ resolveQueueDropBeforeRunId,
+} from "./threadQueueControlPresentation";
+import { threadDragGapOffset } from "./threadDragGap";
+
+describe("threadQueueControlPresentation", () => {
+ it("preserves queue reorder and steer controls with removal", () => {
+ const controls = resolveThreadQueueRowControls({
+ busy: false,
+ canPromoteToSteer: true,
+ canReorder: true,
+ index: 1,
+ queuedCount: 3,
+ text: "Please review the follow-up change.",
+ });
+
+ expect(controls.displayText).toBe("Please review the follow-up change.");
+ expect(controls.canMoveUp).toBe(true);
+ expect(controls.canMoveDown).toBe(true);
+ expect(controls.canSteer).toBe(true);
+ expect(controls.canDismiss).toBe(true);
+ expect(controls.dismissAccessibilityLabel).toBe(REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL);
+ });
+
+ it("disables edge reorder controls and busy dismissal", () => {
+ const first = resolveThreadQueueRowControls({
+ busy: false,
+ canPromoteToSteer: false,
+ canReorder: true,
+ index: 0,
+ queuedCount: 2,
+ text: "First",
+ });
+ const busy = resolveThreadQueueRowControls({
+ busy: true,
+ canPromoteToSteer: true,
+ canReorder: true,
+ index: 0,
+ queuedCount: 1,
+ text: "Queued message",
+ });
+
+ expect(first.canMoveUp).toBe(false);
+ expect(first.canMoveDown).toBe(true);
+ expect(first.canSteer).toBe(false);
+ expect(busy.canDismiss).toBe(false);
+ expect(busy.canMoveUp).toBe(false);
+ expect(busy.canSteer).toBe(false);
+ });
+
+ it("keeps the row already open in the composer from being reopened or steered", () => {
+ const editing = resolveThreadQueueRowControls({
+ busy: false,
+ canPromoteToSteer: true,
+ canReorder: true,
+ index: 1,
+ isEditing: true,
+ queuedCount: 3,
+ text: "Being edited",
+ });
+
+ expect(editing.isEditing).toBe(true);
+ expect(editing.canEdit).toBe(false);
+ expect(editing.canSteer).toBe(false);
+ // Reordering and removing a message stay available while it is edited.
+ expect(editing.canMoveUp).toBe(true);
+ expect(editing.canDismiss).toBe(true);
+ });
+
+ it("builds cancelQueuedRun command arguments for removal", () => {
+ expect(
+ buildCancelQueuedRunCommand({
+ environmentId: "environment:test" as never,
+ runId: "run:queued" as never,
+ threadId: "thread:test" as never,
+ }),
+ ).toEqual({
+ environmentId: "environment:test",
+ input: {
+ runId: "run:queued",
+ threadId: "thread:test",
+ },
+ });
+ });
+});
+
+describe("queue drag insertion", () => {
+ const rows = [
+ { id: "first" as never, y: 0, height: 80 },
+ { id: "second" as never, y: 80, height: 140 },
+ { id: "third" as never, y: 220, height: 80 },
+ ];
+
+ it("moves between variable-height rows and to either end", () => {
+ expect(resolveQueueDropBeforeRunId(rows, rows[0]!.id, 140)).toBe("third");
+ expect(resolveQueueDropBeforeRunId(rows, rows[0]!.id, 300)).toBeNull();
+ expect(resolveQueueDropBeforeRunId(rows, rows[2]!.id, -300)).toBe("first");
+ });
+
+ it("opens the destination gap while the dragged row crosses other rows", () => {
+ const offsets = (runId: (typeof rows)[number]["id"], translation: number) => {
+ const before = resolveQueueDragBeforeRunId(rows, runId, translation);
+ if (before === undefined) return;
+ const source = rows.find((row) => row.id === runId)!;
+ const last = rows.at(-1)!;
+ const insertion =
+ before === null ? last.y + last.height : rows.find((row) => row.id === before)!.y;
+ return rows.map((row) => threadDragGapOffset(row.y, source.y, source.height, insertion));
+ };
+
+ expect(resolveQueueDragBeforeRunId(rows, rows[0]!.id, 0)).toBe(rows[1]!.id);
+ expect(offsets(rows[0]!.id, 140)).toEqual([0, -80, 0]);
+ expect(offsets(rows[0]!.id, 300)).toEqual([0, -80, -80]);
+ expect(offsets(rows[2]!.id, -300)).toEqual([80, 80, 0]);
+ expect(offsets(rows[1]!.id, 0)).toEqual([0, 0, 0]);
+ });
+
+ it("does not send a reorder for an unchanged or unmeasured drop", () => {
+ expect(resolveQueueDropBeforeRunId(rows, rows[1]!.id, 0)).toBeUndefined();
+ expect(resolveQueueDropBeforeRunId(rows, rows[2]!.id, 20)).toBeUndefined();
+ expect(resolveQueueDropBeforeRunId([{ id: rows[0]!.id }], rows[0]!.id, 10)).toBeUndefined();
+ expect(resolveQueueDropBeforeRunId(rows, "missing" as never, 100)).toBeUndefined();
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts
new file mode 100644
index 000000000000..104eee38300d
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts
@@ -0,0 +1,87 @@
+import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
+
+export const REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL = "Remove queued message";
+
+export interface ThreadQueueRowControls {
+ readonly canDismiss: boolean;
+ readonly canEdit: boolean;
+ readonly canMoveDown: boolean;
+ readonly canMoveUp: boolean;
+ readonly canSteer: boolean;
+ readonly dismissAccessibilityLabel: string;
+ readonly displayText: string;
+ readonly isEditing: boolean;
+}
+
+export function resolveThreadQueueRowControls(input: {
+ readonly busy: boolean;
+ readonly canPromoteToSteer: boolean;
+ readonly canReorder: boolean;
+ readonly index: number;
+ /** This row's message is already open in the composer. */
+ readonly isEditing?: boolean;
+ readonly queuedCount: number;
+ readonly text: string;
+}): ThreadQueueRowControls {
+ const mutationEnabled = !input.busy;
+ const isEditing = input.isEditing === true;
+
+ return {
+ canDismiss: !input.busy,
+ // Re-opening the row already in the composer would reload it and throw
+ // away whatever has been typed since.
+ canEdit: mutationEnabled && !isEditing,
+ canMoveDown: mutationEnabled && input.canReorder && input.index < input.queuedCount - 1,
+ canMoveUp: mutationEnabled && input.canReorder && input.index > 0,
+ canSteer: mutationEnabled && input.canPromoteToSteer && !isEditing,
+ dismissAccessibilityLabel: REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL,
+ displayText: input.text,
+ isEditing,
+ };
+}
+
+export function buildCancelQueuedRunCommand(input: {
+ readonly environmentId: EnvironmentId;
+ readonly runId: RunId;
+ readonly threadId: ThreadId;
+}): {
+ readonly environmentId: EnvironmentId;
+ readonly input: {
+ readonly runId: RunId;
+ readonly threadId: ThreadId;
+ };
+} {
+ return {
+ environmentId: input.environmentId,
+ input: {
+ runId: input.runId,
+ threadId: input.threadId,
+ },
+ };
+}
+
+/** Find the insertion anchor using the rows' original, untransformed layout. */
+export function resolveQueueDragBeforeRunId(
+ rows: ReadonlyArray<{ id: RunId; y?: number; height?: number }>,
+ runId: RunId,
+ translationY: number,
+): RunId | null | undefined {
+ const sourceIndex = rows.findIndex((row) => row.id === runId);
+ const source = rows[sourceIndex];
+ if (!source || rows.some((row) => row.y === undefined || row.height === undefined)) return;
+ const center = source.y! + source.height! / 2 + translationY;
+ const remaining = rows.filter((row) => row.id !== runId);
+ return remaining.find((row) => center < row.y! + row.height! / 2)?.id ?? null;
+}
+
+/** Return the insertion anchor after a drag, or undefined when the order is unchanged. */
+export function resolveQueueDropBeforeRunId(
+ rows: ReadonlyArray<{ id: RunId; y?: number; height?: number }>,
+ runId: RunId,
+ translationY: number,
+): RunId | null | undefined {
+ const before = resolveQueueDragBeforeRunId(rows, runId, translationY);
+ if (before === undefined) return;
+ const sourceIndex = rows.findIndex((row) => row.id === runId);
+ return before === (rows[sourceIndex + 1]?.id ?? null) ? undefined : before;
+}
diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts
index b0b2cd42ecba..d42f16ff71c2 100644
--- a/apps/mobile/src/features/threads/use-composer-command-menu.ts
+++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts
@@ -3,11 +3,16 @@ import type {
ProjectId,
ProviderInteractionMode,
ServerProvider,
+ ThreadId,
} from "@t3tools/contracts";
+import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+
+const EMPTY_THREAD_SHELLS: ReadonlyArray = [];
import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts";
import { Alert } from "react-native";
import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
-import { pullRequestComposerContext } from "../../lib/composerContext";
+import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext";
import { uuidv4 } from "../../lib/uuid";
import {
getComposerDraftSnapshot,
@@ -164,6 +169,8 @@ export function useComposerCommandMenu({
draftMessage,
ownerKey,
environmentId,
+ threadShells = EMPTY_THREAD_SHELLS,
+ currentThreadId = null,
projectCwd,
pullRequestProjectId = null,
pullRequestRepository = null,
@@ -179,6 +186,10 @@ export function useComposerCommandMenu({
readonly draftMessage: string;
readonly ownerKey: string | null;
readonly environmentId: EnvironmentId | null;
+ /** Candidates for `@` thread suggestions; the caller reads them from the entity store. */
+ readonly threadShells?: ReadonlyArray;
+ /** Left out of `@` thread suggestions: a thread is never context for itself. */
+ readonly currentThreadId?: ThreadId | null;
readonly projectCwd: string | null;
readonly pullRequestProjectId?: ProjectId | null;
readonly pullRequestRepository?: string | null;
@@ -447,21 +458,35 @@ export function useComposerCommandMenu({
}
if (trigger.kind === "path") {
- return pathSearch.entries.map((entry) => {
- const parts = entry.path.split("/");
- return {
- id: `path:${entry.path}`,
- type: "path" as const,
- path: entry.path,
- kind: entry.kind,
- label: parts[parts.length - 1] ?? entry.path,
- description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
- };
- });
+ const threadItems = environmentId
+ ? matchComposerThreadItems({
+ shells: threadShells,
+ environmentId,
+ excludeThreadId: currentThreadId,
+ query: trigger.query,
+ })
+ : [];
+ return [
+ ...threadItems,
+ ...pathSearch.entries.map((entry) => {
+ const parts = entry.path.split("/");
+ return {
+ id: `path:${entry.path}`,
+ type: "path" as const,
+ path: entry.path,
+ kind: entry.kind,
+ label: parts[parts.length - 1] ?? entry.path,
+ description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
+ };
+ }),
+ ];
}
return [];
}, [
+ currentThreadId,
+ environmentId,
+ threadShells,
hasThread,
hasCompactableConversation,
onUpdateInteractionMode,
@@ -477,6 +502,41 @@ export function useComposerCommandMenu({
const onSelect = useCallback(
(item: ComposerCommandItem) => {
if (!trigger) return;
+ if (item.type === "thread") {
+ if (!ownerKey || trigger.kind !== "path") return;
+ const shell = threadShells.find(
+ (candidate) =>
+ candidate.environmentId === item.thread.environmentId &&
+ candidate.id === item.thread.threadId,
+ );
+ if (!shell) return;
+ const record = threadComposerContext(item.thread, shell.title);
+ const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? [];
+ const alreadyAttached = existing.some((entry) => entry.contextId === record.contextId);
+ if (!alreadyAttached && existing.length >= COMPOSER_CONTEXT_MAX_RECORDS) {
+ Alert.alert(
+ "Too many context items",
+ "Remove some context from the draft and try again.",
+ );
+ return;
+ }
+ const result = replaceTextRange(
+ draftMessage,
+ trigger.rangeStart,
+ trigger.rangeEnd,
+ `${formatComposerContextReference(record)} `,
+ );
+ onChangeDraftMessage(result.text);
+ if (!alreadyAttached) {
+ const draft = getComposerDraftSnapshot(ownerKey);
+ setComposerDraftContext(ownerKey, {
+ version: 1,
+ records: [...(draft.context?.records ?? []), record],
+ });
+ }
+ setSelection({ start: result.cursor, end: result.cursor });
+ return;
+ }
if (item.type === "pull-request") {
if (
!ownerKey ||
@@ -545,6 +605,7 @@ export function useComposerCommandMenu({
onUpdateInteractionMode,
onUsageLimits,
selectedProviderStatus?.showInteractionModeToggle,
+ threadShells,
trigger,
],
);
diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts
index 655535767942..098497d5d36b 100644
--- a/apps/mobile/src/features/threads/use-worktree-setup.ts
+++ b/apps/mobile/src/features/threads/use-worktree-setup.ts
@@ -1,17 +1,14 @@
import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts";
-import {
- findRecordedWorktreeSetup,
- resolveVisibleWorktreeSetup,
-} from "@t3tools/client-runtime/worktree-setup";
+import { resolveVisibleWorktreeSetup } from "@t3tools/client-runtime/worktree-setup";
import { useEffect, useState } from "react";
import { useEnvironmentQuery } from "../../state/query";
import { vcsEnvironment } from "../../state/vcs";
+import { resolveWorktreeSetupSnapshot } from "./worktree-setup-state";
/** Retain the last live snapshot when its subscription closes after setup. */
export function useWorktreeSetup(input: {
environmentId: EnvironmentId | null;
threadId: ThreadId | null;
- activities: ReadonlyArray<{ kind: string; payload: unknown }>;
preparing: boolean;
turnStarted: boolean;
followUpSent: boolean;
@@ -19,32 +16,27 @@ export function useWorktreeSetup(input: {
const key = JSON.stringify([input.environmentId, input.threadId]);
const [held, setHeld] = useState<{ key: string; snapshot: WorktreeSetupSnapshot } | null>(null);
const live = held?.key === key ? held.snapshot : null;
- const recorded = input.threadId
- ? findRecordedWorktreeSetup(input.activities, input.threadId)
- : null;
- const latest = resolveVisibleWorktreeSetup({
- live,
- recorded,
- turnStarted: false,
- followUpSent: false,
- });
const query = useEnvironmentQuery(
input.environmentId &&
input.threadId &&
- (latest?.phase === "running" || (!latest && input.preparing))
+ (live?.phase === "running" || (!live && input.preparing))
? vcsEnvironment.worktreeSetup({
environmentId: input.environmentId,
input: { threadId: input.threadId },
})
: null,
);
+ const snapshot = resolveWorktreeSetupSnapshot(input.threadId, query.data, live);
useEffect(() => {
- if (query.data?.threadId === input.threadId) setHeld({ key, snapshot: query.data });
- }, [key, input.threadId, query.data]);
- return resolveVisibleWorktreeSetup({
- live: query.data ?? live,
- recorded,
- turnStarted: input.turnStarted,
- followUpSent: input.followUpSent,
- });
+ if (snapshot && snapshot !== live) setHeld({ key, snapshot });
+ }, [key, live, snapshot]);
+ return {
+ snapshot,
+ visible: resolveVisibleWorktreeSetup({
+ live: snapshot,
+ recorded: null,
+ turnStarted: input.turnStarted,
+ followUpSent: input.followUpSent,
+ }),
+ };
}
diff --git a/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts b/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts
new file mode 100644
index 000000000000..f29c4bba1643
--- /dev/null
+++ b/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
+
+describe("user message intent badge", () => {
+ it("does not label ordinary turn-start messages", () => {
+ expect(resolveUserMessageIntentBadge(undefined)).toBeNull();
+ expect(resolveUserMessageIntentBadge("turn_start")).toBeNull();
+ });
+
+ it("labels messages waiting behind the active turn", () => {
+ expect(resolveUserMessageIntentBadge("queued_turn")).toEqual({
+ label: "queued",
+ accessibilityLabel: "Queued behind the active turn",
+ tone: "queued",
+ });
+ });
+
+ it("labels messages that steer the active turn", () => {
+ expect(resolveUserMessageIntentBadge("steer")).toEqual({
+ label: "steer",
+ accessibilityLabel: "Steered the active turn",
+ tone: "steer",
+ });
+ });
+
+ it("preserves the queued origin after promotion to steer", () => {
+ expect(resolveUserMessageIntentBadge("promoted_queued_to_steer")).toEqual({
+ label: "queued → steer",
+ accessibilityLabel: "Originally queued, then promoted to steer the active turn",
+ tone: "steer",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/threads/userMessageIntentBadge.ts b/apps/mobile/src/features/threads/userMessageIntentBadge.ts
new file mode 100644
index 000000000000..c72c1c342d83
--- /dev/null
+++ b/apps/mobile/src/features/threads/userMessageIntentBadge.ts
@@ -0,0 +1,35 @@
+import type { OrchestrationV2UserMessageInputIntent } from "@t3tools/contracts";
+
+export interface UserMessageIntentBadgePresentation {
+ readonly label: string;
+ readonly accessibilityLabel: string;
+ readonly tone: "queued" | "steer";
+}
+
+export function resolveUserMessageIntentBadge(
+ intent: OrchestrationV2UserMessageInputIntent | undefined,
+): UserMessageIntentBadgePresentation | null {
+ switch (intent) {
+ case "queued_turn":
+ return {
+ label: "queued",
+ accessibilityLabel: "Queued behind the active turn",
+ tone: "queued",
+ };
+ case "steer":
+ return {
+ label: "steer",
+ accessibilityLabel: "Steered the active turn",
+ tone: "steer",
+ };
+ case "promoted_queued_to_steer":
+ return {
+ label: "queued → steer",
+ accessibilityLabel: "Originally queued, then promoted to steer the active turn",
+ tone: "steer",
+ };
+ case "turn_start":
+ case undefined:
+ return null;
+ }
+}
diff --git a/apps/mobile/src/features/threads/work-log-layout.tsx b/apps/mobile/src/features/threads/work-log-layout.tsx
new file mode 100644
index 000000000000..bd02feb00a39
--- /dev/null
+++ b/apps/mobile/src/features/threads/work-log-layout.tsx
@@ -0,0 +1,74 @@
+import { AppText as Text } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+import type { ComponentProps, ReactNode } from "react";
+import { Pressable, View } from "react-native";
+import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
+
+export function WorkLogBlock({
+ children,
+ layout = "standalone",
+ continues = false,
+}: {
+ children: ReactNode;
+ layout?: "standalone" | "group-header";
+ continues?: boolean | undefined;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function WorkLogRows({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+export function WorkLogIconSlot({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+/** Consumers supply actions/content; sizing comes from the feed's accessibility-aware metrics. */
+export function WorkLogPressable({
+ children,
+ rowSizing,
+ ...props
+}: Omit, "children" | "className" | "style" | "hitSlop"> & {
+ children: ReactNode;
+ rowSizing?: ReturnType;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/** Headers stay one line; only the separate detail content can wrap. */
+export function WorkLogLabel({
+ children,
+ tone = "default",
+}: {
+ children: ReactNode;
+ tone?: "default" | "danger" | "warning";
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/worktree-setup-state.test.ts b/apps/mobile/src/features/threads/worktree-setup-state.test.ts
new file mode 100644
index 000000000000..34ec7df20c69
--- /dev/null
+++ b/apps/mobile/src/features/threads/worktree-setup-state.test.ts
@@ -0,0 +1,78 @@
+import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts";
+import { resolveVisibleWorktreeSetup } from "@t3tools/client-runtime/worktree-setup";
+import { describe, expect, it } from "vite-plus/test";
+import { resolveWorktreeSetupSnapshot } from "./worktree-setup-state";
+
+const threadId = ThreadId.make("setup-thread");
+const running: WorktreeSetupSnapshot = {
+ threadId,
+ sequence: 1,
+ phase: "running",
+ startedAt: "2026-09-17T00:00:00.000Z",
+ endedAt: null,
+ branch: "feature",
+ baseRef: "main",
+ worktreePath: null,
+ setupScript: null,
+ stages: [],
+ error: null,
+};
+
+describe("mobile worktree setup handoff", () => {
+ it("keeps the setup identity after its card retires so the working header can take over", () => {
+ const done = { ...running, phase: "done" as const, sequence: 2 };
+ const held = resolveWorktreeSetupSnapshot(threadId, done, running);
+ expect(held).toBe(done);
+ expect(
+ resolveVisibleWorktreeSetup({
+ live: held,
+ recorded: null,
+ turnStarted: false,
+ followUpSent: false,
+ }),
+ ).toBe(done);
+ expect(
+ resolveVisibleWorktreeSetup({
+ live: held,
+ recorded: null,
+ turnStarted: true,
+ followUpSent: false,
+ }),
+ ).toBeNull();
+ // Closing the live query must not lose the handoff anchor.
+ expect(resolveWorktreeSetupSnapshot(threadId, undefined, held)).toBe(done);
+ });
+
+ it.each(["failed", "cancelled"] as const)(
+ "retains %s details after the stream closes",
+ (phase) => {
+ const settled = { ...running, phase, sequence: 3 };
+ const held = resolveWorktreeSetupSnapshot(threadId, settled, running);
+ expect(resolveWorktreeSetupSnapshot(threadId, null, held)).toBe(settled);
+ expect(resolveWorktreeSetupSnapshot(threadId, running, held)).toBe(settled);
+ expect(
+ resolveVisibleWorktreeSetup({
+ live: held,
+ recorded: null,
+ turnStarted: false,
+ followUpSent: false,
+ }),
+ ).toBe(settled);
+ expect(
+ resolveVisibleWorktreeSetup({
+ live: held,
+ recorded: null,
+ turnStarted: false,
+ followUpSent: true,
+ }),
+ ).toBeNull();
+ },
+ );
+
+ it("does not carry a previous thread's progress across navigation", () => {
+ const otherId = ThreadId.make("other-thread");
+ expect(resolveWorktreeSetupSnapshot(otherId, running, running)).toBeNull();
+ const other = { ...running, threadId: otherId };
+ expect(resolveWorktreeSetupSnapshot(otherId, other, running)).toBe(other);
+ });
+});
diff --git a/apps/mobile/src/features/threads/worktree-setup-state.ts b/apps/mobile/src/features/threads/worktree-setup-state.ts
new file mode 100644
index 000000000000..8a82caa48f15
--- /dev/null
+++ b/apps/mobile/src/features/threads/worktree-setup-state.ts
@@ -0,0 +1,12 @@
+import type { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts";
+
+/** A subscription can close or replay an older value during the setup handoff. */
+export function resolveWorktreeSetupSnapshot(
+ threadId: ThreadId | null,
+ latest: WorktreeSetupSnapshot | null | undefined,
+ held: WorktreeSetupSnapshot | null,
+): WorktreeSetupSnapshot | null {
+ const current = latest?.threadId === threadId ? latest : null;
+ const previous = held?.threadId === threadId ? held : null;
+ return current && (!previous || current.sequence >= previous.sequence) ? current : previous;
+}
diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts
index 81e87b506027..c5422405c966 100644
--- a/apps/mobile/src/lib/composerContext.ts
+++ b/apps/mobile/src/lib/composerContext.ts
@@ -8,12 +8,15 @@ import {
OrchestrationMessageContext,
type PullRequestContextMetadata,
type ReviewCommentContextRecord,
+ type ScopedThreadRef,
+ type ThreadContextRecord,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import {
collectComposerContextReferences,
formatComposerContextReference,
replaceComposerContextReferences,
+ sanitizeComposerContextLabel,
} from "@t3tools/shared/composerContextReferences";
import {
collectComposerInlineTokens,
@@ -98,6 +101,20 @@ export function createComposerContextHistory() {
};
}
+/** Same identity as web: one record per thread, so re-attaching reuses the chip. */
+export function threadComposerContext(ref: ScopedThreadRef, title: string): ThreadContextRecord {
+ const label = sanitizeComposerContextLabel(title, "thread");
+ return {
+ version: 1,
+ kind: "thread",
+ contextId: ComposerContextId.make(`thread_${ref.threadId}`),
+ label,
+ environmentId: ref.environmentId,
+ threadId: ref.threadId,
+ title: label,
+ };
+}
+
export function pullRequestComposerContext(
pullRequest: PullRequestContextMetadata,
id: string,
diff --git a/apps/mobile/src/lib/followUpBehavior.ts b/apps/mobile/src/lib/followUpBehavior.ts
new file mode 100644
index 000000000000..dfb5cae60ac6
--- /dev/null
+++ b/apps/mobile/src/lib/followUpBehavior.ts
@@ -0,0 +1,13 @@
+import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
+
+/**
+ * What the send button does while a turn is already running: `queue` waits for
+ * the turn to finish, `steer` interrupts it with the new message.
+ *
+ * Web keeps the same choice in its per-client settings. Mobile has no
+ * client-settings sync, so it is stored per device alongside the other
+ * composer preferences.
+ */
+export type FollowUpBehavior = Extract;
+
+export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = "queue";
diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts
index 98896b990ec4..463ff5cc3662 100644
--- a/apps/mobile/src/lib/modelOptions.test.ts
+++ b/apps/mobile/src/lib/modelOptions.test.ts
@@ -54,6 +54,44 @@ describe("mobile model options", () => {
]);
});
+ it("carries configured ACP identity into model and provider catalogs", () => {
+ const iconUrl = "https://cdn.agentclientprotocol.com/registry/v1/latest/antigravity-acp.svg";
+ const config = {
+ providers: [
+ {
+ instanceId: "acpRegistry_antigravity",
+ driver: "acpRegistry",
+ displayName: "Antigravity",
+ iconUrl,
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ models: [
+ {
+ slug: "default",
+ name: "Default",
+ isCustom: false,
+ capabilities: null,
+ },
+ ],
+ },
+ ],
+ } as unknown as ServerConfig;
+
+ const [group] = groupByProvider(buildModelOptions(config, null));
+
+ expect(group).toMatchObject({
+ providerKey: "acpRegistry_antigravity",
+ providerLabel: "Antigravity",
+ models: [
+ {
+ providerDriver: "acpRegistry",
+ providerIconUrl: iconUrl,
+ },
+ ],
+ });
+ });
+
it("distinguishes same-name OpenCode models without changing their routing", () => {
const sources = [
{ id: "anthropic", label: "Anthropic" },
@@ -146,6 +184,13 @@ describe("mobile model options", () => {
expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");
expect(option?.selection.options).toBeUndefined();
+ const [emptyOption] = buildModelOptions(config, {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-test",
+ options: [],
+ });
+ expect(emptyOption?.selection).toEqual(option?.selection);
+
const [explicitOption] = buildModelOptions(config, {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-test",
@@ -154,6 +199,56 @@ describe("mobile model options", () => {
expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]);
});
+ it("limits existing threads to their provider while new tasks keep every provider", () => {
+ const providers = ["codex", "claudeAgent"].map((instanceId) => ({
+ instanceId,
+ driver: instanceId,
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ models: [{ slug: "test", name: instanceId, capabilities: null }],
+ }));
+ const config = { providers } as unknown as ServerConfig;
+ const selection = { instanceId: ProviderInstanceId.make("codex"), model: "test" };
+
+ expect(buildModelOptions(config, selection).map((option) => option.providerKey)).toEqual([
+ "codex",
+ "claudeAgent",
+ ]);
+ expect(buildModelOptions(config, selection, selection.instanceId)).toEqual(
+ buildModelOptions(config, selection).filter((option) => option.providerKey === "codex"),
+ );
+ });
+
+ it.each(["disabled", "unavailable", "missing"] as const)(
+ "retains the selected %s provider's fallback in a filtered catalog",
+ (state) => {
+ const selection = {
+ instanceId: ProviderInstanceId.make("google_work"),
+ model: "saved-model",
+ options: [{ id: "native-option", value: "saved-choice" }],
+ };
+ const provider = {
+ instanceId: selection.instanceId,
+ driver: "antigravity",
+ displayName: "Google Work",
+ enabled: state !== "disabled",
+ installed: true,
+ availability: state === "unavailable" ? "unavailable" : "available",
+ auth: { status: "authenticated" },
+ models: [{ slug: selection.model, name: "Saved model", capabilities: null }],
+ };
+ const config = {
+ providers: state === "missing" ? [] : [provider],
+ settings: { providerInstances: { google_work: { driver: "antigravity" } } },
+ } as unknown as ServerConfig;
+ const options = buildModelOptions(config, selection, selection.instanceId);
+ expect(options).toEqual(buildModelOptions(config, selection));
+ expect(options).toHaveLength(1);
+ expect(options[0]).toMatchObject({ selection, isUnavailable: true });
+ },
+ );
+
it("rejects stored selections whose provider is not usable", () => {
const config = {
providers: [
diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts
index 4e8295733141..4469becee1a2 100644
--- a/apps/mobile/src/lib/modelOptions.ts
+++ b/apps/mobile/src/lib/modelOptions.ts
@@ -1,6 +1,8 @@
+import type { MenuAction } from "@react-native-menu/menu";
import type {
ModelCapabilities,
ModelSelection,
+ RuntimeMode,
ServerConfig as T3ServerConfig,
} from "@t3tools/contracts";
import {
@@ -15,6 +17,8 @@ export type ModelOption = {
readonly providerKey: string;
readonly providerLabel: string;
readonly providerDriver: string;
+ readonly supportedRuntimeModes?: ReadonlyArray;
+ readonly providerIconUrl?: string | undefined;
readonly isDefault: boolean;
readonly isLegacy: boolean;
readonly isUnavailable?: boolean;
@@ -36,6 +40,7 @@ function providerDisplayLabel(provider: {
if (provider.displayName) return provider.displayName;
if (provider.driver === "codex") return "Codex";
if (provider.driver === "claudeAgent") return "Claude";
+ if (provider.driver === "pi") return "Pi";
return provider.instanceId;
}
@@ -46,6 +51,9 @@ function normalizeSelectionOptions(
if (!capabilities) {
return selection;
}
+ if (!selection.options?.length) {
+ return { instanceId: selection.instanceId, model: selection.model };
+ }
const options = buildExplicitProviderOptionSelectionsFromDescriptors(
getProviderOptionDescriptors({
caps: capabilities,
@@ -150,11 +158,13 @@ export function resolveNewTaskModelSelection(input: {
export function buildModelOptions(
config: T3ServerConfig | null | undefined,
fallbackModelSelection: ModelSelection | null,
+ providerInstanceId?: ModelSelection["instanceId"],
): ReadonlyArray {
const options = new Map();
for (const provider of config?.providers ?? []) {
if (
+ (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||
!provider.enabled ||
!provider.installed ||
provider.auth.status === "unauthenticated" ||
@@ -173,6 +183,10 @@ export function buildModelOptions(
providerKey: provider.instanceId,
providerLabel,
providerDriver: provider.driver,
+ ...(provider.supportedRuntimeModes === undefined
+ ? {}
+ : { supportedRuntimeModes: provider.supportedRuntimeModes }),
+ ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),
isDefault: model.isDefault === true,
isLegacy: model.isLegacy === true,
capabilities: model.capabilities,
@@ -187,7 +201,10 @@ export function buildModelOptions(
}
}
- if (fallbackModelSelection) {
+ if (
+ fallbackModelSelection &&
+ (providerInstanceId === undefined || fallbackModelSelection.instanceId === providerInstanceId)
+ ) {
const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`;
const existing = options.get(key);
if (existing) {
@@ -254,3 +271,53 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr
models: group.models,
}));
}
+
+function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction {
+ return {
+ id: `model:${option.key}`,
+ title: option.label,
+ state:
+ option.selection.instanceId === selectedModel?.instanceId &&
+ option.selection.model === selectedModel.model
+ ? "on"
+ : undefined,
+ };
+}
+
+export function buildModelMenuActions(
+ groups: ReadonlyArray,
+ selectedModel: ModelSelection | null,
+): MenuAction[] {
+ return groups.flatMap((group) => {
+ const currentModels = group.models.filter((model) => !model.isLegacy);
+ const legacyModels = group.models.filter((model) => model.isLegacy);
+ const selected = group.models.find(
+ (model) =>
+ model.selection.instanceId === selectedModel?.instanceId &&
+ model.selection.model === selectedModel.model,
+ );
+
+ return [
+ ...(currentModels.length > 0
+ ? [
+ {
+ id: `provider:${group.providerKey}`,
+ title: group.providerLabel,
+ subtitle: selected && !selected.isLegacy ? selected.label : undefined,
+ subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)),
+ },
+ ]
+ : []),
+ ...(legacyModels.length > 0
+ ? [
+ {
+ id: `legacy-models:${group.providerKey}`,
+ title: `${group.providerLabel} legacy models`,
+ subtitle: selected?.isLegacy ? selected.label : undefined,
+ subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)),
+ },
+ ]
+ : []),
+ ];
+ });
+}
diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts
index 57df389cff3e..f67a4649face 100644
--- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts
+++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts
@@ -19,6 +19,40 @@ describe("project thread title", () => {
expect(deriveThreadTitleFromPrompt(" \n ")).toBe("New thread");
});
+ it("derives attachment-only titles from prepared image metadata", () => {
+ const uploadedAttachments = [
+ {
+ type: "image" as const,
+ id: "prepared-photo",
+ name: "photo.png",
+ mimeType: "image/png",
+ sizeBytes: 3,
+ },
+ ];
+ const input = buildProjectThreadStartTurnInput({
+ projectId: ProjectId.make("project"),
+ projectCwd: "/workspace",
+ threadId: "image-thread",
+ commandId: "image-command",
+ messageId: "image-message",
+ createdAt: "2026-09-04T00:00:00Z",
+ text: "",
+ uploadedAttachments,
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ workspaceMode: "local",
+ branch: null,
+ worktreePath: null,
+ startFromOrigin: false,
+ worktreeBranchName: "unused",
+ });
+
+ expect(input.titleSeed).toBe("Image: photo.png");
+ expect(input.bootstrap.createThread.title).toBe(input.titleSeed);
+ expect(input.message.attachments).toEqual(uploadedAttachments);
+ });
+
it.each([
{
comment: undefined,
@@ -26,7 +60,7 @@ describe("project thread title", () => {
},
{
comment: 'Why "shared"?',
- title: 'Keep `cache[key]` & shared. Retry! Comment: Why "shared"?',
+ title: "Keep `cache[key]` & shared. Retry! Commen...",
},
])("uses readable titles and intact links with comment $comment", ({ comment, title }) => {
const quoteText = "Keep `cache[key]` & shared.\n Retry!";
diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts
index f895888e1486..fa1cc704219a 100644
--- a/apps/mobile/src/lib/projectThreadStartTurn.ts
+++ b/apps/mobile/src/lib/projectThreadStartTurn.ts
@@ -2,26 +2,18 @@ import {
CommandId,
MessageId,
ThreadId,
+ type ChatAttachment,
type ModelSelection,
type OrchestrationMessageContext,
type ProjectId,
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
+import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import type { UploadedMobileAttachment } from "./attachmentUpload";
-export function deriveThreadTitleFromPrompt(value: string): string {
- const trimmed = assistantCitationsToPlainText(value).trim();
- if (trimmed.length === 0) {
- return "New thread";
- }
-
- const compact = trimmed.replace(/\s+/g, " ");
- return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
-}
-
export interface ProjectThreadStartTurnSpec {
readonly projectId: ProjectId;
readonly projectCwd: string;
@@ -31,8 +23,8 @@ export interface ProjectThreadStartTurnSpec {
readonly createdAt: string;
readonly text: string;
readonly context?: OrchestrationMessageContext;
- /** Wire attachments from `prepareTurnAttachments`, in composer order. */
- readonly uploadedAttachments: ReadonlyArray;
+ /** New uploads or server-owned attachments from a cancelled setup. */
+ readonly uploadedAttachments: ReadonlyArray;
readonly modelSelection: ModelSelection;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
@@ -50,10 +42,11 @@ export interface ProjectThreadStartTurnSpec {
* offline outbox drain so both deliver identical commands.
*/
export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) {
- const title = deriveThreadTitleFromPrompt(spec.text);
+ const title = deriveThreadTitleSeed({ text: spec.text, attachments: spec.uploadedAttachments });
const isWorktree = spec.workspaceMode === "worktree";
return {
commandId: CommandId.make(spec.commandId),
+ creationSource: "mobile" as const,
threadId: ThreadId.make(spec.threadId),
message: {
messageId: MessageId.make(spec.messageId),
@@ -92,3 +85,13 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
createdAt: spec.createdAt,
};
}
+
+export function deriveThreadTitleFromPrompt(value: string): string {
+ const trimmed = assistantCitationsToPlainText(value).trim();
+ if (trimmed.length === 0) {
+ return "New thread";
+ }
+
+ const compact = trimmed.replace(/\s+/g, " ");
+ return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
+}
diff --git a/apps/mobile/src/lib/scopedEntities.ts b/apps/mobile/src/lib/scopedEntities.ts
index 34709957fd48..6464b3561919 100644
--- a/apps/mobile/src/lib/scopedEntities.ts
+++ b/apps/mobile/src/lib/scopedEntities.ts
@@ -1,4 +1,4 @@
-import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
+import { EnvironmentId, ProjectId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string {
return `${environmentId}:${projectId}`;
@@ -10,7 +10,7 @@ export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId
export function scopedRequestKey(
environmentId: EnvironmentId,
- requestId: ApprovalRequestId,
+ requestId: RuntimeRequestId,
): string {
return `${environmentId}:${requestId}`;
}
diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts
index 5d256f7c6206..4f0c140ac229 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -1,2082 +1,1171 @@
-import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
-import { beforeEach, describe, expect, it } from "vite-plus/test";
-
import {
- ApprovalRequestId,
- EventId,
+ ContextHandoffId,
MessageId,
- ProjectId,
+ CheckpointId,
+ CheckpointScopeId,
+ RuntimeRequestId,
+ NodeId,
+ PlanId,
ProviderInstanceId,
+ ProviderDriverKind,
+ ProviderThreadId,
+ ProviderTurnId,
+ RunId,
+ RunAttemptId,
+ ScheduledTaskId,
ThreadId,
- TurnId,
- type OrchestrationThread,
- type OrchestrationThreadActivity,
+ TurnItemId,
+ type OrchestrationV2RunAttempt,
+ type OrchestrationV2ProjectedTurnItem,
+ type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
+import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
+import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation";
+import * as DateTime from "effect/DateTime";
+import { describe, expect, it } from "vite-plus/test";
import {
- agentSpawnSummary,
- buildPendingUserInputAnswers,
+ workEntryRowLabel,
+ isContextHandoffActivityGroup,
buildThreadFeed,
deriveThreadFeedPresentation,
- isPendingUserInputOptionSelected,
- setPendingUserInputCustomAnswer,
- togglePendingUserInputOptionSelection,
- workEntryRowLabel,
+ threadFeedActivityIsVisible,
+ threadFeedRunIsUnsettled,
type ThreadFeedActivity,
type ThreadFeedEntry,
- type WorkLogEntry,
+ togglePendingUserInputOptionSelection,
+ setPendingUserInputCustomAnswer,
+ isPendingUserInputOptionSelected,
+ buildPendingUserInputAnswers,
} from "./threadActivity";
-// Match Hermes: these ES2023 array methods are absent on mobile.
-beforeEach(() => {
- const methods = ["toSorted", "toReversed"] as const;
- const descriptors = methods.map((method) =>
- Object.getOwnPropertyDescriptor(Array.prototype, method),
- );
- for (const method of methods) Reflect.deleteProperty(Array.prototype, method);
- return () => {
- for (const [index, method] of methods.entries()) {
- const descriptor = descriptors[index];
- if (descriptor) Reflect.defineProperty(Array.prototype, method, descriptor);
- }
- };
+const threadId = ThreadId.make("thread-1");
+const sourceThreadId = ThreadId.make("thread-source");
+const runId = RunId.make("run-1");
+
+it("keeps historical plan detail accessible from its paged turn item", () => {
+ const item = {
+ ...base("historical-plan", "2026-08-29T00:00:00.000Z", 1),
+ type: "proposed_plan",
+ planId: "plan-historical",
+ markdown: "Full historical plan text",
+ streaming: false,
+ } as OrchestrationV2TurnItem;
+
+ const entries = buildThreadFeed([projected(item, 0)]);
+ const activity = entries.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ )[0];
+ expect(activity?.detail).toBe("Full historical plan text");
+ expect(activity?.getFullDetail()).toContain("Full historical plan text");
});
-const singleSelectQuestion = {
- id: "runtime",
- header: "Runtime",
- question: "Which runtime should be used?",
- options: [
- { label: "Go", description: "One binary" },
- { label: "Node.js", description: "Reuse TypeScript" },
- ],
- multiSelect: false,
-} as const;
-
-const multiSelectQuestion = {
- id: "scope",
- header: "Scope",
- question: "Which data should be collected?",
- options: [
- { label: "Orders", description: "Receipts" },
- { label: "Listings", description: "Inventory" },
- ],
- multiSelect: true,
-} as const;
-
-const nativeQuestion = {
- id: "choice",
- header: "File",
- question: "Which file should be used?",
- options: [
- { label: "Use this", description: "First file", value: " choice " },
- { label: "Use this", description: "Second file", value: "choice" },
- ],
- multiSelect: false,
- allowCustomAnswer: false,
-} as const;
-
-describe("pending user input answers", () => {
- it("accepts free-text answers to async questions without options", () => {
- const question = {
- id: "0",
- header: "Question",
- question: "What should it be named?",
- options: [],
- allowCustomAnswer: true,
- multiSelect: false,
- };
- const requested = makeActivity({
- id: EventId.make("async-question"),
- kind: "user-input.requested",
- summary: "User input requested",
- createdAt: "2026-09-03T00:00:00.000Z",
- payload: { requestId: "async-1", responseMode: "message", questions: [question] },
- });
- const questions = derivePendingRequests([requested]).userInputs[0]?.questions;
- expect(questions).toEqual([question]);
- expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({
- "0": "Example",
- });
- });
-
- it("preserves native choice values and custom-answer rules from activities", () => {
- const requested = makeActivity({
- id: EventId.make("native-question"),
- kind: "user-input.requested",
- summary: "User input requested",
- createdAt: "2026-09-02T00:00:00.000Z",
- payload: {
- requestId: "interaction_1",
- questions: [nativeQuestion, singleSelectQuestion],
- },
- });
-
- expect(derivePendingRequests([requested]).userInputs).toEqual([
- {
- requestId: "interaction_1",
- createdAt: requested.createdAt,
- dismissible: false,
- questions: [nativeQuestion, singleSelectQuestion],
- },
- ]);
- });
-
- it("replaces single-select options and toggles multi-select options", () => {
- expect(
- togglePendingUserInputOptionSelection(
- singleSelectQuestion,
- { selectedOptionValues: ["Go"] },
- "Node.js",
- ),
- ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] });
-
- const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
- const ordersAndListings = togglePendingUserInputOptionSelection(
- multiSelectQuestion,
- orders,
- "Listings",
- );
- expect(ordersAndListings).toEqual({
- customAnswer: "",
- selectedOptionValues: ["Orders", "Listings"],
- });
- expect(
- togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
- ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
-
- const paddedOrders = togglePendingUserInputOptionSelection(
- multiSelectQuestion,
- undefined,
- " Orders ",
- );
- expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
- expect(
- togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
- ).toEqual({ customAnswer: "" });
- });
-
- it("builds array answers for multi-select questions", () => {
- expect(
- buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
- runtime: { selectedOptionValues: ["Go"] },
- scope: { selectedOptionValues: ["Orders", "Listings"] },
- }),
- ).toEqual({
- runtime: "Go",
- scope: ["Orders", "Listings"],
- });
- });
-
- it("clears selected options while a custom answer is active", () => {
- expect(
- setPendingUserInputCustomAnswer(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders", "Listings"] },
- "Orders first",
- ),
- ).toEqual({ customAnswer: "Orders first" });
- });
-
- it("matches selected options against normalized legacy labels", () => {
- expect(
- isPendingUserInputOptionSelected(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders"] },
- " Orders ",
- ),
- ).toBe(true);
- expect(
- isPendingUserInputOptionSelected(
- multiSelectQuestion,
- { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
- " Orders ",
- ),
- ).toBe(false);
- });
+it("shows only the structured path in expanded mobile read details", () => {
+ const item: OrchestrationV2TurnItem = {
+ ...base("read-detail", "2026-06-20T00:00:03.000Z", 2),
+ type: "dynamic_tool",
+ toolName: "Read",
+ title: "Read src/env.ts",
+ input: { path: "src/env.ts" },
+ output: "---\nname: env\n---\nsecret content",
+ };
+ const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ )[0];
+
+ expect(activity?.getFullDetail()).toBe("src/env.ts");
+ expect(activity?.canExpand).toBe(true);
+ expect(activity?.getCopyText()).not.toContain("secret content");
+ expect(activity?.getFullDetail()).not.toContain("sourceThreadId");
+
+ const withoutPath = buildThreadFeed([
+ projected({ ...item, id: TurnItemId.make("read-without-path"), input: {} }, 0),
+ ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0];
+ expect(withoutPath?.getFullDetail()).toBeNull();
+ expect(withoutPath?.canExpand).toBe(false);
+});
- it("keeps custom answers enabled for legacy questions", () => {
- expect(
- buildPendingUserInputAnswers([singleSelectQuestion], {
- runtime: { selectedOptionValues: ["Go"], customAnswer: " Use Bun " },
- }),
- ).toEqual({ runtime: "Use Bun" });
- });
+it("labels file searches with the adapter title and its search target", () => {
+ const item: OrchestrationV2TurnItem = {
+ ...base("file-search", "2026-06-20T00:00:03.000Z", 2),
+ type: "file_search",
+ title: "Searched TODO in web",
+ pattern: "TODO",
+ };
+ const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ )[0];
- it("keeps duplicate labels and whitespace-sensitive native values separate", () => {
- const first = togglePendingUserInputOptionSelection(nativeQuestion, undefined, " choice ");
- expect(isPendingUserInputOptionSelected(nativeQuestion, first, " choice ")).toBe(true);
- expect(isPendingUserInputOptionSelected(nativeQuestion, first, "choice")).toBe(false);
- expect(buildPendingUserInputAnswers([nativeQuestion], { choice: first })).toEqual({
- choice: " choice ",
- });
+ expect(activity?.summary).toBe("Searched TODO in web");
+ expect(activity ? workEntryRowLabel(activity.workEntry) : null).toBe("Searched TODO in web");
+});
- const second = togglePendingUserInputOptionSelection(nativeQuestion, first, "choice");
- expect(isPendingUserInputOptionSelected(nativeQuestion, second, " choice ")).toBe(false);
- expect(isPendingUserInputOptionSelected(nativeQuestion, second, "choice")).toBe(true);
- expect(buildPendingUserInputAnswers([nativeQuestion], { choice: second })).toEqual({
- choice: "choice",
- });
- });
+it("keeps approval prompts rather than presenting them as tool work", () => {
+ const approval = (
+ id: string,
+ requestKind: "file-read" | "command" | "file-change",
+ ordinal: number,
+ ) =>
+ ({
+ ...base(id, `2026-06-20T00:00:0${ordinal}.000Z`, ordinal),
+ type: "approval_request",
+ requestId: RuntimeRequestId.make(`request-${id}`),
+ requestKind,
+ prompt: `Allow ${requestKind}?`,
+ }) satisfies OrchestrationV2TurnItem;
+ const feed = buildThreadFeed([
+ projected(approval("approve-read", "file-read", 1), 0),
+ projected(approval("approve-command", "command", 2), 1),
+ projected(approval("approve-edit", "file-change", 3), 2),
+ ]);
+ const activities = feed.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
- it("keeps exact native values in multi-select answers", () => {
- const question = { ...nativeQuestion, multiSelect: true };
- const first = togglePendingUserInputOptionSelection(question, undefined, " choice ");
- const both = togglePendingUserInputOptionSelection(question, first, "choice");
- expect(buildPendingUserInputAnswers([question], { choice: both })).toEqual({
- choice: [" choice ", "choice"],
- });
+ expect(activities.map((activity) => workEntryRowLabel(activity.workEntry))).toEqual([
+ "Allow file-read?",
+ "Allow command?",
+ "Allow file-change?",
+ ]);
+ expect(activities[0]?.canExpand).toBe(true);
+ expect(
+ summarizeToolGroup(activities.slice(1).map((activity) => activity.workEntry)).summary,
+ ).not.toMatch(/Ran|changed/);
+});
- const second = togglePendingUserInputOptionSelection(question, both, " choice ");
- expect(buildPendingUserInputAnswers([question], { choice: second })).toEqual({
- choice: ["choice"],
- });
- });
+function base(id: string, updatedAt: string, ordinal: number) {
+ const timestamp = DateTime.makeUnsafe(updatedAt);
+ return {
+ id: TurnItemId.make(id),
+ threadId,
+ runId,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal,
+ status: "completed" as const,
+ title: null,
+ startedAt: timestamp,
+ completedAt: timestamp,
+ updatedAt: timestamp,
+ };
+}
- it("ignores custom answers when a question only accepts choices", () => {
- const draft = { selectedOptionValues: [" choice "], customAnswer: "Other" };
- expect(setPendingUserInputCustomAnswer(nativeQuestion, draft, "Custom text")).toBe(draft);
- expect(isPendingUserInputOptionSelected(nativeQuestion, draft, " choice ")).toBe(true);
- expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toEqual({
- choice: " choice ",
- });
- });
+function projected(
+ item: OrchestrationV2TurnItem,
+ position: number,
+ visibility: OrchestrationV2ProjectedTurnItem["visibility"] = "local",
+): OrchestrationV2ProjectedTurnItem {
+ return {
+ position,
+ visibility,
+ sourceThreadId: visibility === "local" ? threadId : sourceThreadId,
+ sourceItemId: item.id,
+ item,
+ };
+}
- it.each([
- { customAnswer: "Other" },
- { selectedOptionValues: ["Use this"] },
- { selectedOptionValues: ["not offered"] },
- { selectedOptionValues: [" choice "] },
- ])("requires an offered value for a choice-only question: %j", (draft) => {
- expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toBeNull();
- });
-});
+function userMessage(updatedAt = "2026-06-20T00:00:01.000Z") {
+ return {
+ ...base("item-user", updatedAt, 0),
+ type: "user_message" as const,
+ messageId: MessageId.make("message-user"),
+ createdBy: "user" as const,
+ creationSource: "mobile" as const,
+ inputIntent: "turn_start" as const,
+ text: "Run checks",
+ attachments: [],
+ };
+}
-function makeActivity(
- input: Partial &
- Pick,
-): OrchestrationThreadActivity {
+function command(updatedAt = "2026-06-20T00:00:02.000Z") {
return {
- tone: "info",
- payload: {},
- turnId: null,
- ...input,
+ ...base("item-command", updatedAt, 1),
+ type: "command_execution" as const,
+ input: "vp check",
+ output: "ok",
+ exitCode: 0,
};
}
-function makeThread(
- input: Partial & Pick,
-): OrchestrationThread {
+function assistantMessage(updatedAt = "2026-06-20T00:00:03.000Z") {
return {
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- pullRequests: [],
- latestTurn: null,
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- archivedAt: null,
- deletedAt: null,
- messages: [],
- proposedPlans: [],
- activities: [],
- checkpoints: [],
- session: null,
- ...input,
- settledOverride: input.settledOverride ?? null,
- settledAt: input.settledAt ?? null,
+ ...base("item-assistant", updatedAt, 2),
+ type: "assistant_message" as const,
+ messageId: MessageId.make("message-assistant"),
+ text: "Done",
+ streaming: false,
};
}
describe("buildThreadFeed", () => {
- it("reuses unchanged feed and presentation rows during an assistant text update", () => {
- const completedTurnId = TurnId.make("completed-turn");
- const activeTurnId = TurnId.make("active-turn");
- const thread = makeThread({
- id: ThreadId.make("feed-reuse"),
- projectId: ProjectId.make("project-1"),
- title: "Feed reuse",
- messages: [
- {
- id: MessageId.make("completed-message"),
- role: "assistant",
- text: "Completed response",
- turnId: completedTurnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:01.000Z",
- updatedAt: "2026-04-01T00:00:01.000Z",
- },
- {
- id: MessageId.make("streaming-message"),
- role: "assistant",
- text: "Current response",
- turnId: activeTurnId,
- streaming: true,
- createdAt: "2026-04-01T00:00:04.000Z",
- updatedAt: "2026-04-01T00:00:04.000Z",
- },
- ],
- activities: [
- makeActivity({
- id: EventId.make("completed-tool"),
- kind: "tool.completed",
- summary: "Read files",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId: completedTurnId,
- payload: { itemType: "file_read", status: "completed" },
- }),
- makeActivity({
- id: EventId.make("active-tool"),
- kind: "tool.updated",
- summary: "Run checks",
- createdAt: "2026-04-01T00:00:03.000Z",
- turnId: activeTurnId,
- payload: { itemType: "command_execution", command: "vp test", status: "inProgress" },
- }),
- ],
- });
- const latestTurn = {
- turnId: activeTurnId,
- state: "running" as const,
- startedAt: "2026-04-01T00:00:03.000Z",
- completedAt: null,
+ it("keeps async answers in question history instead of user bubbles", () => {
+ const requestId = RuntimeRequestId.make("question");
+ const question: OrchestrationV2TurnItem = {
+ ...base("question", "2026-06-20T00:00:01.000Z", 0),
+ type: "user_input_request",
+ requestId,
+ questions: [],
+ questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} },
};
- const expandedTurns = new Set([completedTurnId]);
- const expandedGroups = new Set(["work-group:completed-tool", "work-group:active-tool"]);
- const previousFeed = buildThreadFeed(thread);
- const previousRows = deriveThreadFeedPresentation(
- previousFeed,
- latestTurn,
- expandedTurns,
- expandedGroups,
- latestTurn.startedAt,
- );
- const updatedMessage = {
- ...thread.messages[1]!,
- text: "Current response with more text",
- updatedAt: "2026-04-01T00:00:05.000Z",
+ const reply: OrchestrationV2TurnItem = {
+ ...userMessage(),
+ id: TurnItemId.make("answer"),
+ messageId: MessageId.make(`async-answer:${requestId}`),
};
- const nextFeed = buildThreadFeed({
- ...thread,
- messages: [thread.messages[0]!, updatedMessage],
+ const feed = buildThreadFeed([projected(question, 0), projected(reply, 1)]);
+ expect(feed).toHaveLength(1);
+ expect(feed[0]).toMatchObject({
+ type: "activity-group",
+ activities: [{ workEntry: { questionAnswer: question.questionAnswer } }],
});
- const nextRows = deriveThreadFeedPresentation(
- nextFeed,
- latestTurn,
- expandedTurns,
- expandedGroups,
- latestTurn.startedAt,
- );
+ expect(buildThreadFeed([projected(reply, 0)])[0]?.type).toBe("message");
+ });
- expect(nextFeed).toHaveLength(previousFeed.length);
- expect(nextRows).toHaveLength(previousRows.length);
- for (const [before, after] of [
- [previousFeed, nextFeed],
- [previousRows, nextRows],
- ] as const) {
- for (const [index, row] of after.entries()) {
- if (row.id === updatedMessage.id) {
- expect(row).not.toBe(before[index]);
- expect(row).toMatchObject({ message: updatedMessage });
- } else {
- expect(row).toBe(before[index]);
- }
- }
+ it("does not create a work group for a message and checkpoint", () => {
+ const checkpoint: OrchestrationV2TurnItem = {
+ ...base("checkpoint", "2026-06-20T00:00:04.000Z", 3),
+ type: "checkpoint",
+ checkpointId: CheckpointId.make("checkpoint"),
+ scopeId: CheckpointScopeId.make("scope"),
+ files: [],
+ };
+ const feed = buildThreadFeed([projected(assistantMessage(), 0), projected(checkpoint, 1)]);
+ expect(feed.map((entry) => entry.type)).toEqual(["message"]);
+ });
+
+ it("omits cached tool output and patch bodies from expanded and copied activity", () => {
+ const rawOutput = "RAW_TOOL_OUTPUT";
+ const items: OrchestrationV2TurnItem[] = [
+ { ...command(), output: rawOutput },
+ {
+ ...base("dynamic-output", "2026-06-20T00:00:03.000Z", 2),
+ type: "dynamic_tool",
+ toolName: "example",
+ input: { query: "keep input" },
+ output: { text: rawOutput },
+ },
+ {
+ ...base("file-output", "2026-06-20T00:00:04.000Z", 3),
+ type: "file_change",
+ fileName: "src/example.ts",
+ diffStr: rawOutput,
+ oldStr: rawOutput,
+ newStr: rawOutput,
+ },
+ ];
+ const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap(
+ (entry) => (entry.type === "activity-group" ? entry.activities : []),
+ );
+ expect(activities).toHaveLength(3);
+ for (const activity of activities) {
+ expect(activity.workEntry.detail).toBeUndefined();
+ expect(activity.getFullDetail()).not.toContain(rawOutput);
+ expect(activity.getCopyText()).not.toContain(rawOutput);
}
- expect(nextRows.some((row) => row.type === "turn-fold")).toBe(true);
- expect(nextRows.some((row) => row.type === "activity-group")).toBe(true);
+ expect(activities[0]?.detail).toBe("vp check");
+ expect(activities[1]?.getFullDetail()).toContain("keep input");
+ expect(activities[2]?.detail).toBe("src/example.ts");
+ expect(items[0]).toMatchObject({ output: rawOutput });
});
- it("regroups cached activities for message changes and pagination", () => {
- const messages = [2, 4].map((second) => ({
- id: MessageId.make(`message-${second}`),
- role: "assistant" as const,
- text: second === 2 ? "" : "Response",
- streaming: false,
- turnId: null,
- createdAt: `2026-04-01T00:00:0${second}.000Z`,
- updatedAt: `2026-04-01T00:00:0${second}.000Z`,
- }));
- const thread = makeThread({
- id: ThreadId.make("feed-regroup"),
- projectId: ProjectId.make("project-1"),
- title: "Feed grouping",
- messages,
- activities: [1, 3, 5].map((second) =>
- makeActivity({
- id: EventId.make(`work-${second}`),
- kind: "runtime.warning",
- summary: `Notice ${second}`,
- createdAt: `2026-04-01T00:00:0${second}.000Z`,
- }),
+ it("recognizes automation attribution after projecting a user message", () => {
+ const feed = buildThreadFeed([
+ projected(
+ {
+ ...userMessage(),
+ createdBy: "agent",
+ creationSource: "server",
+ scheduledTaskId: ScheduledTaskId.make("daily-audit"),
+ },
+ 0,
),
- });
- const initial = buildThreadFeed(thread);
- expect(initial.map((row) => row.id)).toEqual(["work-1", "message-4", "work-5"]);
- const split = buildThreadFeed({
- ...thread,
- messages: [{ ...messages[0]!, text: "Now visible" }, messages[1]!],
- });
- expect(split.map((row) => row.id)).toEqual([
- "work-1",
- "message-2",
- "work-3",
- "message-4",
- "work-5",
]);
- expect(split[0]).not.toBe(initial[0]);
- expect(split.at(-1)).toBe(initial.at(-1));
- expect(initial[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }] });
+ const messageEntry = feed.find((entry) => entry.type === "message");
- const reordered = buildThreadFeed({
- ...thread,
- messages: [messages[0]!, { ...messages[1]!, createdAt: "2026-04-01T00:00:06.000Z" }],
+ expect(messageEntry).toBeDefined();
+ expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({
+ text: "Run checks",
+ isAutomation: true,
});
- expect(reordered.map((row) => row.id)).toEqual(["work-1", "message-4"]);
- expect(reordered[0]).toMatchObject({
- activities: [{ id: "work-1" }, { id: "work-3" }, { id: "work-5" }],
- });
- const olderMessage = {
- ...messages[1]!,
- id: MessageId.make("older-message"),
- createdAt: "2026-04-01T00:00:00.000Z",
- };
- const page = buildThreadFeed(thread, {
- loadedMessages: [messages[1]!],
- localMessages: [olderMessage],
- });
- expect(page.map((row) => row.id)).toEqual(["older-message", "message-4", "work-5"]);
- const prepended = buildThreadFeed(thread, { loadedMessages: [olderMessage, ...messages] });
- expect(prepended.map((row) => row.id)).toEqual([
- "older-message",
- "work-1",
- "message-4",
- "work-5",
+ });
+
+ it("keeps the sender of an agent message distinct from its timeline source", () => {
+ const feed = buildThreadFeed([
+ projected(
+ {
+ ...userMessage(),
+ createdBy: "agent",
+ creationSource: "mcp",
+ senderThreadId: sourceThreadId,
+ },
+ 0,
+ ),
]);
- expect(prepended.at(-1)).toBe(page.at(-1));
+ const messageEntry = feed.find((entry) => entry.type === "message");
+ expect(messageEntry?.message.senderThreadId).toBe(sourceThreadId);
+ expect(messageEntry?.message.sourceThreadId).toBe(threadId);
});
- it("keeps context compaction as a standalone timeline row", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-context-compaction"),
- projectId: ProjectId.make("project-1"),
- title: "Context compaction",
- activities: [
- makeActivity({
- id: EventId.make("context-compaction"),
- kind: "context-compaction",
- tone: "info",
- summary: "Compacted context 899K → 19K tokens",
- createdAt: "2026-09-01T00:00:00.000Z",
- turnId: TurnId.make("turn-context-compaction"),
- }),
- ],
+ it("anchors feedback before later committed turns", () => {
+ const laterUser = {
+ ...userMessage("2026-08-29T00:00:05.000Z"),
+ id: TurnItemId.make("item-later-user"),
+ messageId: MessageId.make("message-later-user"),
+ ordinal: 2,
+ text: "Later user turn",
+ };
+ const laterAssistant = {
+ ...assistantMessage("2026-08-29T00:00:04.000Z"),
+ id: TurnItemId.make("item-later-assistant"),
+ messageId: MessageId.make("message-later-assistant"),
+ ordinal: 3,
+ text: "Later assistant turn",
+ };
+ const localMessage = (id: string, role: "user" | "assistant") => ({
+ id: MessageId.make(id),
+ role,
+ text: id,
+ turnId: null,
+ streaming: false,
+ createdAt: "2026-08-29T00:00:03.000Z",
+ updatedAt: "2026-08-29T00:00:03.000Z",
});
-
- const presented = deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set());
- expect(presented).toMatchObject([
+ const feed = buildThreadFeed(
+ [
+ projected(userMessage("2026-08-29T00:00:01.000Z"), 0),
+ projected(laterUser, 1),
+ projected(laterAssistant, 2),
+ ],
{
- type: "activity-group",
- id: "context-compaction",
- activities: [{ summary: "Compacted context 899K → 19K tokens" }],
+ anchoredMessages: [
+ localMessage("feedback-user", "user"),
+ localMessage("feedback-assistant", "assistant"),
+ localMessage("message-later-user", "user"),
+ ],
},
+ );
+ const messages = feed.filter((entry) => entry.type === "message");
+
+ expect(messages.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "feedback-user",
+ "feedback-assistant",
+ "message-later-user",
+ "message-later-assistant",
]);
+ expect(
+ messages
+ .filter((entry) => entry.id.startsWith("feedback-"))
+ .every((entry) => entry.message.projectedItem === undefined),
+ ).toBe(true);
});
- it("keeps long Claude commands expandable without repeating them in full detail", () => {
- const command = `printf 'first line\nsecond line'\n&& printf done`;
- const thread = makeThread({
- id: ThreadId.make("thread-long-command"),
- projectId: ProjectId.make("project-1"),
- title: "Long command",
- activities: [
- makeActivity({
- id: EventId.make("long-command"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Command run",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- detail: `Bash: ${command}`,
- data: { toolName: "Bash", command },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row).toMatchObject({ detail: command, canExpand: true });
- expect(row?.getFullDetail()).toBe(command);
- expect(row?.getCopyText()).toBe(`Command run\n${command}`);
+ it("keeps prominent activity visible while it is running", () => {
+ expect(
+ threadFeedActivityIsVisible({ prominent: true, status: "neutral", toolLike: true }),
+ ).toBe(true);
+ expect(
+ threadFeedActivityIsVisible({ prominent: false, status: "neutral", toolLike: true }),
+ ).toBe(false);
});
- it("keeps command output when it equals the displayed command", () => {
- const command = "printf hello";
- const thread = makeThread({
- id: ThreadId.make("thread-matching-command-output"),
- projectId: ProjectId.make("project-1"),
- title: "Matching output",
- activities: [
- makeActivity({
- id: EventId.make("matching-command-output"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Command run",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- detail: `Bash: ${command}`,
- data: { toolName: "Bash", command, rawOutput: { content: command } },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.detail).toBe(command);
- expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
- expect(row?.getCopyText()).toBe(`Command run\n${command}\n\n${command}`);
+ it("keeps provider notices visible outside completed work folds without failure styling", () => {
+ const message = "Safeguards flagged this message. Switched to Opus 4.8.";
+ const item = {
+ ...base("item-system-notice", "2026-06-20T00:00:02.000Z", 1),
+ type: "system_notice" as const,
+ message,
+ };
+ const feed = buildThreadFeed([projected(item, 0)]);
+ const presented = deriveThreadFeedPresentation(feed, null, new Set());
+ const activities = presented.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
+ expect(activities).toHaveLength(1);
+ expect(activities[0]).toMatchObject({
+ summary: message,
+ detail: message,
+ prominent: true,
+ toolLike: false,
+ status: null,
+ icon: "warning",
+ workEntry: { tone: "info", itemType: "system_notice" },
+ });
+ expect(presented.some((entry) => entry.type === "run-fold")).toBe(false);
});
- it("keeps OpenCode detail-only output when it equals the command", () => {
- const command = "printf hello";
- const thread = makeThread({
- id: ThreadId.make("thread-opencode-detail-output"),
- projectId: ProjectId.make("project-1"),
- title: "OpenCode detail output",
- activities: [
- makeActivity({
- id: EventId.make("opencode-detail-output"),
- kind: "tool.completed",
- tone: "tool",
- summary: "bash",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "bash",
- detail: command,
- data: { command },
- },
- }),
- ],
+ it("presents a usage-limit stop as a warning while preserving its explanation", () => {
+ const message = "Plan usage limit reached. Try again after reset.";
+ const entries = buildThreadFeed([
+ projected(
+ {
+ ...base("item-limit", "2026-06-20T00:00:02.000Z", 1),
+ type: "error",
+ status: "failed",
+ title: "Usage limit reached",
+ failure: { class: "usage_limit", message, code: "usageLimitExceeded", retryable: null },
+ },
+ 0,
+ ),
+ ]);
+ const activity = entries.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ )[0];
+ expect(activity).toMatchObject({
+ summary: "Usage limit reached",
+ status: "neutral",
+ icon: "warning",
});
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.workEntry.detail).toBe(command);
- expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
- expect(row?.canExpand).toBe(true);
- expect(workEntryRowLabel(row!.workEntry, true)).toBe("Command");
+ expect(activity?.getFullDetail()).toContain(message);
});
- it.each([
- {
- name: "a task summary that is its own detail",
- activity: {
- kind: "task.completed" as const,
- tone: "info" as const,
- summary: "Task completed",
- payload: {
- taskId: "bh2p996o4",
- status: "completed",
- title: "Check CI on the new head",
- summary: "Check CI on the new head",
- detail: "Check CI on the new head",
- agentKind: "background",
- taskType: "local_bash",
+ it.each(["transport_error", "usage_limit"] as const)(
+ "presents %s retries and clears warning markers on recovery",
+ (failureClass) => {
+ const retryBase = {
+ ...base("item-provider-retry", "2026-06-20T00:00:02.000Z", 1),
+ type: "error" as const,
+ failure: {
+ class: failureClass,
+ message: "The response stream disconnected.",
+ code: "responseStreamDisconnected",
+ retryable: true,
},
- },
- label: "Check CI on the new head",
- canExpand: false,
- },
- {
- name: "a runtime warning with only its message",
- activity: {
- kind: "runtime.warning" as const,
- tone: "info" as const,
- summary: "Bash is unusable in this environment",
- payload: { detail: "Bash is unusable in this environment" },
- },
- label: "Bash is unusable in this environment",
- canExpand: true,
- },
- {
- name: "a multi-line task report",
- activity: {
- kind: "task.completed" as const,
- tone: "info" as const,
- summary: "Task completed",
- payload: {
- taskId: "bpxcizf97",
- status: "completed",
- title: "Audit the PR",
- detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.",
- agentKind: "background",
- taskType: "local_bash",
- },
- },
- label: "**Tooling note:** Bash is unusable. # Audit No blockers.",
- canExpand: true,
- },
- {
- name: "a command whose output differs from the command",
- activity: {
- kind: "tool.completed" as const,
- tone: "tool" as const,
- summary: "Command run",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- detail: "Bash: printf hello",
- data: { toolName: "Bash", command: "printf hello", rawOutput: { content: "hello" } },
+ retry: {
+ attempt: 2,
+ maxAttempts: 5,
+ retryDelayMs: null,
},
- },
- label: "printf hello",
- canExpand: true,
- },
- ])("sets expansion availability for $name: $canExpand", (input) => {
- const thread = makeThread({
- id: ThreadId.make("thread-expand-rule"),
- projectId: ProjectId.make("project-1"),
- title: "Expand rule",
- activities: [
- makeActivity({
- id: EventId.make("expand-rule"),
- createdAt: "2026-09-01T00:00:00.000Z",
- ...input.activity,
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(workEntryRowLabel(row!.workEntry)).toBe(input.label);
- expect(row?.canExpand).toBe(input.canExpand);
- });
+ };
+ const runningFeed = buildThreadFeed([
+ projected(
+ {
+ ...retryBase,
+ status: "running",
+ title: "Provider retry",
+ completedAt: null,
+ },
+ 0,
+ ),
+ ]);
+ const recoveredFeed = buildThreadFeed([
+ projected(
+ {
+ ...retryBase,
+ status: "completed",
+ title: "Provider recovered",
+ },
+ 0,
+ ),
+ ]);
+ if (failureClass === "usage_limit") {
+ const recoveredActivity = recoveredFeed.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ )[0];
+ expect(recoveredActivity).toMatchObject({ status: "success", icon: "check" });
+ }
+ const failedFeed = buildThreadFeed([
+ projected(
+ {
+ ...retryBase,
+ status: "failed",
+ title: "Provider retry failed",
+ },
+ 0,
+ ),
+ projected(command("2026-06-20T00:00:03.000Z"), 1),
+ ]);
+ const runningActivity = runningFeed.find((entry) => entry.type === "activity-group")
+ ?.activities[0];
+ const recoveredActivity = recoveredFeed.find((entry) => entry.type === "activity-group")
+ ?.activities[0];
+ if (runningActivity === undefined || recoveredActivity === undefined) {
+ throw new Error("Expected provider retry work-log activities.");
+ }
- it.each(["runtime.error", "runtime.warning"] as const)(
- "shows and copies the message of %s without a duplicate expanded body",
- (kind) => {
- const message =
- "You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 5:21 AM.";
- const thread = makeThread({
- id: ThreadId.make("runtime-message"),
- projectId: ProjectId.make("project-1"),
- title: "Runtime message",
- activities: [
- makeActivity({
- id: EventId.make("runtime-message"),
- createdAt: "2026-09-01T00:00:00.000Z",
- kind,
- tone: "error",
- summary: "Runtime error",
- payload: { message },
- }),
- ],
+ expect(runningActivity).toMatchObject({
+ summary: "Provider retry",
+ status: "neutral",
+ toolLike: false,
+ });
+ expect(threadFeedActivityIsVisible(runningActivity)).toBe(true);
+ expect(recoveredActivity).toMatchObject({
+ summary: "Provider recovered",
+ status: "success",
+ toolLike: false,
});
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const row = group.activities[0]!;
- expect(row.canExpand).toBe(true);
- expect(workEntryRowLabel(row.workEntry, true)).toBe(message);
- expect(row.getFullDetail()).toBeNull();
- expect(row.getCopyText()).toBe(`Runtime error\n${message}`);
+ const failedPresentation = deriveThreadFeedPresentation(
+ failedFeed,
+ { runId, status: "running", startedAt: null, completedAt: null },
+ new Set(),
+ );
+ expect(failedPresentation.map((entry) => entry.type)).toEqual([
+ "activity-group",
+ "activity-group",
+ ]);
+ expect(
+ failedPresentation[0]?.type === "activity-group"
+ ? failedPresentation[0].activities[0]?.summary
+ : null,
+ ).toBe("Provider retry failed");
},
);
- it.each([
- {
- message: "fallback message",
- detail: "More specific detail",
- expected: "More specific detail",
- },
- { message: " ", expected: undefined },
- { message: { error: "not a string" }, expected: undefined },
- ])("preserves existing runtime details and ignores invalid messages: $message", (input) => {
- const thread = makeThread({
- id: ThreadId.make("runtime-detail"),
- projectId: ProjectId.make("project-1"),
- title: "Runtime detail",
- activities: [
- makeActivity({
- id: EventId.make("runtime-detail"),
- createdAt: "2026-09-01T00:00:00.000Z",
- kind: "runtime.error",
- tone: "error",
- summary: "Runtime error",
- payload: { message: input.message, detail: input.detail },
- }),
- ],
- });
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- expect(group.activities[0]?.workEntry.detail).toBe(input.expected);
- expect(group.activities[0]?.canExpand).toBe(Boolean(input.expected));
- });
-
- it.each([
- {
- summary: "Runtime error",
- kind: "runtime.error" as const,
- detail:
- "Request failed.\nThe service returned an unexpected response.\nRetry after checking the connection.",
- },
- {
- summary: "Web search",
- kind: "tool.completed" as const,
- detail: "https://itanium-cxx-abi.github.io/cxx-abi/abi.html",
- itemType: "web_search",
+ it.each(["pending", "running", "completed"] as const)(
+ "omits %s task progress without hiding adjacent conversation items",
+ (stepStatus) => {
+ const todoItem = {
+ ...base("item-tasks", "2026-06-20T00:00:02.500Z", 2),
+ type: "todo_list" as const,
+ planId: PlanId.make("plan-tasks"),
+ steps: [{ id: "step-1", text: "Verify the change", status: stepStatus }],
+ } satisfies OrchestrationV2TurnItem;
+ const user = projected(userMessage(), 0);
+ const tool = projected(command(), 1);
+ const assistant = projected(assistantMessage(), 3);
+
+ expect(buildThreadFeed([user, tool, projected(todoItem, 2), assistant])).toEqual(
+ buildThreadFeed([user, tool, assistant]),
+ );
},
- ])("expands the full $summary label once, retaining its formatting and copy text", (input) => {
- const thread = makeThread({
- id: ThreadId.make("expanded-label"),
- projectId: ProjectId.make("project-1"),
- title: "Expanded label",
- activities: [
- makeActivity({
- id: EventId.make("expanded-label"),
- createdAt: "2026-09-01T00:00:00.000Z",
- kind: input.kind,
- summary: input.summary,
- payload: { detail: input.detail, itemType: input.itemType },
- }),
- ],
- });
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const row = group.activities[0]!;
- expect(row.canExpand).toBe(true);
- expect(workEntryRowLabel(row.workEntry)).toBe(input.detail.replace(/\s+/g, " "));
- expect(workEntryRowLabel(row.workEntry, true)).toBe(input.detail);
- expect(row.getFullDetail()).toBeNull();
- expect(row.getCopyText()).toBe(`${input.summary}\n${input.detail}`);
- });
+ );
- it("drops a truncated Claude echo of a long command", () => {
- const command = `git add -A && git commit -m "${"x".repeat(200)}"`;
- const thread = makeThread({
- id: ThreadId.make("thread-truncated-echo"),
- projectId: ProjectId.make("project-1"),
- title: "Truncated echo",
- activities: [
- makeActivity({
- id: EventId.make("truncated-echo"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Command run",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- detail: `Bash: ${command}`.slice(0, 177) + "...",
- data: { toolName: "Bash", command },
- },
- }),
- ],
- });
+ it("hides synthetic workspace preparation activity", () => {
+ const workspacePreparation = projected(
+ {
+ ...command(),
+ title: "Workspace ready",
+ input: "Preparing workspace",
+ output: "Workspace preparation completed.",
+ },
+ 0,
+ );
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.workEntry.detail).toBeUndefined();
- expect(row?.getFullDetail()).toBe(command);
+ expect(buildThreadFeed([workspacePreparation])).toEqual([]);
});
- it("drops an ACP command echo when the update omits the tool kind", () => {
- const command = "pnpm test";
- const thread = makeThread({
- id: ThreadId.make("thread-acp-no-kind"),
- projectId: ProjectId.make("project-1"),
- title: "ACP no kind",
- activities: [
- makeActivity({
- id: EventId.make("acp-no-kind"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Terminal",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Terminal",
- detail: command,
- data: { toolCallId: "tool-1", command },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.workEntry.detail).toBeUndefined();
- expect(row?.getFullDetail()).toBe(command);
+ it("does not treat a queued-only run as live feed activity", () => {
+ expect(
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "queued",
+ startedAt: null,
+ completedAt: null,
+ }),
+ ).toBe(false);
+ expect(
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "running",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ }),
+ ).toBe(true);
+ expect(
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "completed",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ }),
+ ).toBe(true);
+ expect(
+ threadFeedRunIsUnsettled({
+ runId,
+ status: "waiting",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ }),
+ ).toBe(true);
});
- it("drops ACP command metadata when detail only repeats the command", () => {
- const command = "printf hello";
- const thread = makeThread({
- id: ThreadId.make("thread-acp-command-detail"),
- projectId: ProjectId.make("project-1"),
- title: "ACP command detail",
- activities: [
- makeActivity({
- id: EventId.make("acp-command-detail"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Terminal",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Terminal",
- detail: command,
- data: { kind: "execute", command },
- },
- }),
- ],
- });
+ it("adds queued input only after dispatch creates its turn item", () => {
+ const dispatchedRunId = RunId.make("run-dispatched-queued");
+ const dispatchedMessageId = MessageId.make("message-dispatched-queued");
+ expect(buildThreadFeed([])).toEqual([]);
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- const [row] = group.activities;
- expect(row?.workEntry.detail).toBeUndefined();
- expect(row?.getFullDetail()).toBe(command);
+ const promotedEntries = buildThreadFeed([
+ projected(
+ {
+ ...userMessage(),
+ id: TurnItemId.make("item-dispatched-queued"),
+ runId: dispatchedRunId,
+ messageId: dispatchedMessageId,
+ inputIntent: "turn_start",
+ },
+ 0,
+ ),
+ ]);
+ expect(promotedEntries.map((entry) => entry.id)).toEqual([dispatchedMessageId]);
+ expect(
+ promotedEntries[0]?.type === "message" ? promotedEntries[0].message.inputIntent : undefined,
+ ).toBe("turn_start");
});
- it("does not show command output when the command input is missing", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-command-without-input"),
- projectId: ProjectId.make("project-1"),
- title: "Missing command input",
- activities: [
- makeActivity({
- id: EventId.make("command-without-input"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Command run",
- createdAt: "2026-09-01T00:00:00.000Z",
- payload: {
- itemType: "command_execution",
- title: "Command run",
- data: { rawOutput: { content: "output without command metadata" } },
- },
- }),
- ],
- });
-
- const [group] = buildThreadFeed(thread);
- expect(group?.type).toBe("activity-group");
- if (group?.type !== "activity-group") return;
- expect(group.activities[0]?.detail).toBeNull();
- expect(group.activities[0]?.getFullDetail()).toBeNull();
- });
+ it("hides the interruption request and keeps the terminal result", () => {
+ const request = projected(
+ {
+ ...base("item-interrupt-request", "2026-06-20T00:00:02.000Z", 1),
+ type: "run_interrupt_request",
+ message: "Interrupt requested",
+ },
+ 0,
+ );
+ const result = projected(
+ {
+ ...base("item-interrupt-result", "2026-06-20T00:00:03.000Z", 2),
+ type: "run_interrupt_result",
+ message: "Run interrupted before provider start",
+ },
+ 1,
+ );
- it("keeps setup failures visible without routine setup notices before or after a turn", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-worktree-setup"),
- projectId: ProjectId.make("project-1"),
- title: "Worktree setup",
- activities: [
- makeActivity({
- id: EventId.make("setup-requested"),
- kind: "setup-script.requested",
- summary: "Starting setup script",
- createdAt: "2026-08-30T00:00:00.000Z",
- }),
- makeActivity({
- id: EventId.make("setup-started"),
- kind: "setup-script.started",
- summary: "Setup script started",
- createdAt: "2026-08-30T00:00:01.000Z",
- }),
- makeActivity({
- id: EventId.make("setup-failed"),
- kind: "setup-script.failed",
- summary: "Setup script failed to start",
- createdAt: "2026-08-30T00:00:02.000Z",
- tone: "error",
- payload: { detail: "Setup command was not found" },
- }),
- ],
- });
- const latestTurn = {
- turnId: TurnId.make("turn-after-setup"),
- state: "running" as const,
- requestedAt: "2026-08-30T00:00:03.000Z",
- startedAt: "2026-08-30T00:00:04.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
+ const activities = buildThreadFeed([request, result]).flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
- for (const currentTurn of [null, latestTurn]) {
- const currentThread = { ...thread, latestTurn: currentTurn };
- const feed = buildThreadFeed(currentThread);
- expect(feed).toMatchObject([
+ expect(activities).toHaveLength(1);
+ expect(activities[0]?.summary).toBe("Run interrupted");
+ expect(activities[0]?.detail).toBe("Run interrupted before provider start");
+ expect(
+ deriveThreadFeedPresentation(
+ buildThreadFeed([request, result]),
{
- type: "activity-group",
- activities: [{ id: "setup-failed", status: "failure" }],
+ runId,
+ status: "interrupted",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
},
- ]);
- const group = feed[0];
- if (group?.type !== "activity-group") throw new Error("Expected the setup failure group");
- expect(group.activities[0]?.getCopyText()).toContain("Setup command was not found");
- }
- });
-
- it("leaves failed setup snapshots to the setup card", () => {
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-setup-failed"),
- projectId: ProjectId.make("project-1"),
- title: "Failed setup",
- activities: [
- makeActivity({
- id: EventId.make("worktree-failed"),
- kind: "worktree-setup",
- summary: "Worktree setup failed",
- createdAt: "2026-08-30T00:00:00.000Z",
- tone: "error",
- }),
- ],
- }),
- );
- expect(feed).toEqual([]);
+ new Set(),
+ ).some((entry) => entry.type === "run-fold"),
+ ).toBe(false);
});
- it.each(["setup-script.requested", "setup-script.started"])(
- "keeps error-toned %s notices visible",
- (kind) => {
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-setup-error"),
- projectId: ProjectId.make("project-1"),
- title: "Setup error",
- activities: [
- makeActivity({
- id: EventId.make("setup-error"),
- kind,
- summary: "Setup failed",
- createdAt: "2026-08-30T00:00:00.000Z",
- tone: "error",
- }),
- ],
- }),
- );
-
- expect(feed).toMatchObject([
- { type: "activity-group", activities: [{ id: "setup-error", status: "failure" }] },
- ]);
- },
- );
+ it("preserves authoritative V2 order instead of sorting reconstructed collections", () => {
+ const rows = [
+ projected(userMessage("2026-06-20T00:00:03.000Z"), 0),
+ projected(command("2026-06-20T00:00:01.000Z"), 1),
+ projected(assistantMessage("2026-06-20T00:00:02.000Z"), 2),
+ ];
- it("keeps historic work entries attributed to their turns", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-1"),
- projectId: ProjectId.make("project-1"),
- title: "Runtime warning thread",
- latestTurn: {
- turnId: TurnId.make("turn-latest"),
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("activity-old"),
- kind: "runtime.warning",
- summary: "Runtime warning",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId: TurnId.make("turn-old"),
- payload: {
- message: "Old warning",
- },
- }),
- makeActivity({
- id: EventId.make("activity-latest"),
- kind: "runtime.warning",
- summary: "Runtime warning",
- createdAt: "2026-04-01T00:00:03.000Z",
- turnId: TurnId.make("turn-latest"),
- payload: {
- message: "Latest warning",
- },
- }),
- ],
- });
+ const feed = buildThreadFeed(rows);
+ expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]);
+ expect(feed.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "local:thread-1:item-command",
+ "message-assistant",
+ ]);
+ const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0];
+ expect(activity?.projectedItem).toBe(rows[1]);
+ expect(activity?.getFullDetail()).toContain('"input": "vp check"');
+ });
- const feed = buildThreadFeed(thread);
- expect(feed).toMatchObject([
+ it("keeps adjacent work from different V2 attempts in separate groups", () => {
+ const firstRootNodeId = NodeId.make("node-attempt-1");
+ const secondRootNodeId = NodeId.make("node-attempt-2");
+ const firstCommand = { ...command(), nodeId: firstRootNodeId };
+ const secondCommand = {
+ ...command("2026-06-20T00:00:03.000Z"),
+ id: TurnItemId.make("item-command-retry"),
+ ordinal: 2,
+ nodeId: secondRootNodeId,
+ };
+ const attempts = [
{
- type: "activity-group",
- turnId: "turn-old",
- activities: [{ id: "activity-old", turnId: "turn-old" }],
+ id: RunAttemptId.make("attempt-1"),
+ runId,
+ attemptOrdinal: 1,
+ rootNodeId: firstRootNodeId,
+ providerInstanceId: ProviderInstanceId.make("provider-instance-1"),
+ providerThreadId: ProviderThreadId.make("provider-thread-1"),
+ providerTurnId: null,
+ reason: "initial",
+ status: "completed",
+ startedAt: DateTime.makeUnsafe("2026-06-20T00:00:01.000Z"),
+ completedAt: DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"),
},
{
- type: "activity-group",
- turnId: "turn-latest",
- activities: [{ id: "activity-latest", turnId: "turn-latest" }],
+ id: RunAttemptId.make("attempt-2"),
+ runId,
+ attemptOrdinal: 2,
+ rootNodeId: secondRootNodeId,
+ providerInstanceId: ProviderInstanceId.make("provider-instance-1"),
+ providerThreadId: ProviderThreadId.make("provider-thread-1"),
+ providerTurnId: null,
+ reason: "retry",
+ status: "completed",
+ startedAt: DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"),
+ completedAt: DateTime.makeUnsafe("2026-06-20T00:00:03.000Z"),
},
- ]);
- });
+ ] satisfies ReadonlyArray;
- it("drops runtime warnings with no displayable content", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-noise"),
- projectId: ProjectId.make("project-1"),
- title: "Warning noise thread",
- activities: [
- makeActivity({
- id: EventId.make("activity-noise"),
- kind: "runtime.warning",
- summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId: TurnId.make("turn-1"),
- }),
- makeActivity({
- id: EventId.make("activity-signal"),
- kind: "runtime.warning",
- summary: "Reconnecting... 2/5",
- createdAt: "2026-04-01T00:00:03.000Z",
- turnId: TurnId.make("turn-1"),
- }),
- ],
+ const feed = buildThreadFeed([projected(firstCommand, 0), projected(secondCommand, 1)], {
+ attempts,
});
- const feed = buildThreadFeed(thread);
- expect(feed).toMatchObject([
- {
- type: "activity-group",
- activities: [{ id: "activity-signal" }],
- },
- ]);
+ expect(feed).toHaveLength(2);
+ expect(
+ feed.map((entry) =>
+ entry.type === "activity-group" ? entry.activities[0]?.attemptId : null,
+ ),
+ ).toEqual(["attempt-1", "attempt-2"]);
});
- it("collapses matching tool lifecycle rows like desktop", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-2"),
- projectId: ProjectId.make("project-1"),
- title: "Collapsed tools",
- latestTurn: {
- turnId: TurnId.make("turn-1"),
- state: "completed",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: "2026-04-01T00:00:03.000Z",
- assistantMessageId: null,
+ it("retains inherited and synthetic rows with their original projected identity", () => {
+ const inherited = projected(command(), 0, "inherited");
+ const { providerThreadId: _providerThreadId, ...forkBase } = base(
+ "item-fork",
+ "2026-06-20T00:00:03.000Z",
+ 2,
+ );
+ const synthetic = projected(
+ {
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId: sourceThreadId, runId },
+ targetThreadId: threadId,
},
- activities: [
- makeActivity({
- id: EventId.make("tool-updated"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Run tests",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId: TurnId.make("turn-1"),
- payload: {
- title: "Run tests",
- itemType: "command_execution",
- detail: "/bin/zsh -lc 'bun run test'",
- },
- }),
- makeActivity({
- id: EventId.make("tool-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Run tests completed",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId: TurnId.make("turn-1"),
- payload: {
- title: "Run tests",
- itemType: "command_execution",
- detail: "/bin/zsh -lc 'bun run test'",
- },
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- const group = feed[0];
-
- expect(group).toMatchObject({
- type: "activity-group",
- });
- if (!group || group.type !== "activity-group") {
- return;
- }
+ 1,
+ "synthetic",
+ );
- expect(group.activities).toHaveLength(1);
- expect(group.activities[0]).toMatchObject({
- id: "tool-updated",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId: "turn-1",
- summary: "Run tests",
- detail: "bun run test",
- canExpand: true,
- icon: "command",
- toolLike: true,
- status: "success",
- });
- expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'");
- expect(group.activities[0]?.getCopyText()).toBe(
- "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
+ const feed = buildThreadFeed([inherited, synthetic]);
+ const activities = feed.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
);
+ expect(activities.map((activity) => activity.projectedItem)).toEqual([inherited, synthetic]);
+ expect(activities.map((activity) => activity.projectedItem.visibility)).toEqual([
+ "inherited",
+ "synthetic",
+ ]);
+ expect(activities.at(-1)?.prominent).toBe(true);
});
- it("keeps viewed image metadata while collapsing a streamed Claude Read", () => {
- const turnId = TurnId.make("turn-image-read");
- const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`;
- const thread = makeThread({
- id: ThreadId.make("thread-image-read"),
- projectId: ProjectId.make("project-1"),
- title: "Image read",
- activities: [
- makeActivity({
- id: EventId.make("image-read-update"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Image view",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- payload: {
- toolCallId: "tool-read-image",
- itemType: "image_view",
- status: "inProgress",
- detail: `${imagePath.slice(0, 177)}...`,
- data: { imagePath },
- },
- }),
- makeActivity({
- id: EventId.make("image-read-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Image view",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- toolCallId: "tool-read-image",
- itemType: "image_view",
- status: "completed",
- detail: `${imagePath.slice(0, 177)}...`,
- data: {},
- },
- }),
- ],
- });
-
- const group = buildThreadFeed(thread)[0];
- expect(group).toMatchObject({
- type: "activity-group",
- activities: [
+ it("keeps orchestration relationship cards visible when a completed run is folded", () => {
+ const { providerThreadId: _providerThreadId, ...forkBase } = base(
+ "item-fork",
+ "2026-06-20T00:00:02.500Z",
+ 2,
+ );
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(command(), 1),
+ projected(
{
- workEntry: {
- itemType: "image_view",
- viewedImagePath: imagePath,
- },
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId, runId },
+ targetThreadId: sourceThreadId,
},
- ],
- });
- if (group?.type !== "activity-group") return;
- const row = group.activities[0]!;
- expect(row.canExpand).toBe(true);
- expect(row.getFullDetail()).toBeNull();
- expect(workEntryRowLabel(row.workEntry, true)).toBe(`${imagePath.slice(0, 177)}...`);
- });
+ 2,
+ ),
+ projected(assistantMessage(), 3),
+ ]);
- it("keeps MCP inputs available to expanded mobile work rows", () => {
- const turnId = TurnId.make("turn-mcp");
- const thread = makeThread({
- id: ThreadId.make("thread-mcp"),
- projectId: ProjectId.make("project-1"),
- title: "Expandable MCP call",
- latestTurn: {
- turnId,
- state: "completed",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: "2026-04-01T00:00:03.000Z",
- assistantMessageId: null,
+ const collapsed = deriveThreadFeedPresentation(
+ feed,
+ {
+ runId,
+ status: "completed",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
},
- activities: [
- makeActivity({
- id: EventId.make("mcp-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Call repository tool",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- title: "Call repository tool",
- itemType: "mcp_tool_call",
- toolSurface: "computer",
- toolIcon: {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- },
- toolSource: {
- key: "native-app:com.example.editor",
- name: "Computer Use",
- kind: "computer",
- icon: {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- },
- },
- detail: "repository.search",
- status: "completed",
- data: {
- item: {
- server: "repository",
- tool: "search",
- arguments: { query: "work log" },
- },
- },
- },
- }),
- ],
- });
-
- const group = buildThreadFeed(thread)[0];
- expect(group).toMatchObject({ type: "activity-group" });
- if (!group || group.type !== "activity-group") {
- return;
- }
+ new Set(),
+ );
- expect(group.activities[0]?.icon).toBe("computer");
- expect(group.activities[0]?.workEntry.toolSurface).toBe("computer");
- expect(group.activities[0]?.workEntry.toolIcon).toEqual({
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- });
- expect(group.activities[0]?.workEntry.toolSource).toEqual({
- key: "native-app:com.example.editor",
- name: "Computer Use",
- kind: "computer",
- icon: {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- },
- });
- expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"');
- expect(workEntryRowLabel(group.activities[0]!.workEntry, true)).toBe("repository.search");
- expect(group.activities[0]?.getFullDetail()).not.toContain("repository.search");
+ expect(
+ collapsed.some(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities.some((activity) => activity.projectedItem.item.type === "fork"),
+ ),
+ ).toBe(true);
+ expect(
+ collapsed.some(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities.some(
+ (activity) => activity.projectedItem.item.type === "command_execution",
+ ),
+ ),
+ ).toBe(false);
});
- it.each([
- {
- source: "raw MCP browser identity",
- label: "Call MCP tool",
- title: "Call MCP tool",
- item: { server: "t3-code", tool: "preview_navigate" },
- status: "inProgress",
- displayName: "Navigating the preview browser",
- icon: "browser",
- },
- {
- source: "raw MCP orchestration identity",
- label: "Call MCP tool",
- title: "Call MCP tool",
- item: { server: "t3-code", tool: "task_status" },
- status: "inProgress",
- displayName: "Getting delegated task status",
- icon: "t3-code",
- },
- {
- source: "provider-qualified title",
- label: "Call MCP tool",
- title: "mcp__t3-code__preview_snapshot",
- item: undefined,
- status: "inProgress",
- displayName: "Taking a snapshot of the preview page",
- icon: "browser",
- },
- {
- source: "provider-qualified label",
- label: "mcp__t3-code__task_status",
- title: undefined,
- item: undefined,
- status: "inProgress",
- displayName: "Getting delegated task status",
- icon: "t3-code",
- },
- {
- source: "browser identity without lifecycle status",
- label: "Call MCP tool",
- title: "Call MCP tool",
- item: { server: "t3-code", tool: "preview_click" },
- status: undefined,
- displayName: "Clicking in the preview browser",
- liveDisplayName: "Clicking in the preview browser",
- settledDisplayName: "Clicked in the preview browser",
- icon: "browser",
- },
- {
- source: "orchestration identity without lifecycle status",
- label: "Call MCP tool",
- title: "Call MCP tool",
- item: { server: "t3-code", tool: "task_status" },
- status: undefined,
- displayName: "Getting delegated task status",
- liveDisplayName: "Getting delegated task status",
- settledDisplayName: "Got delegated task status",
- icon: "t3-code",
- },
- ])(
- "uses friendly row and running labels from $source",
- ({ label, title, item, status, displayName, liveDisplayName, settledDisplayName, icon }) => {
- const turnId = TurnId.make("turn-friendly-mcp");
- const rawCommand = "node mcp-call.js";
- const rawDetail = '{"provider":"raw MCP output"}';
- const thread = makeThread({
- id: ThreadId.make("thread-friendly-mcp"),
- projectId: ProjectId.make("project-1"),
- title: "Friendly MCP labels",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("friendly-mcp"),
- kind: "tool.updated",
- tone: "tool",
- summary: label,
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- title,
- itemType: "mcp_tool_call",
- detail: rawDetail,
- status,
- data: { item, command: rawCommand },
- },
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- const group = feed[0];
- expect(group).toMatchObject({
- type: "activity-group",
- activities: [{ summary: displayName, detail: rawCommand }],
- });
- if (!group || group.type !== "activity-group") return;
- const activity = group.activities[0]!;
- expect(activity.getFullDetail()).toContain(rawCommand);
- expect(activity.getFullDetail()).toContain(rawDetail);
- expect(activity.getCopyText()).toContain(rawCommand);
- expect(activity.getCopyText()).toContain(rawDetail);
- expect(activity.getCopyText()).not.toContain(displayName);
- if (item) expect(activity.getFullDetail()).toContain(JSON.stringify(item, null, 2));
- expect(
- deriveThreadFeedPresentation(
- feed,
- thread.latestTurn,
- new Set(),
- new Set(),
- thread.latestTurn!.startedAt,
- ),
- ).toMatchObject([
- {
- type: "work-toggle",
- summary: liveDisplayName ?? displayName,
- summaryToolIcon: icon,
- live: true,
- },
- ]);
- if (settledDisplayName) {
- const settledRows = deriveThreadFeedPresentation(
- feed,
- {
- ...thread.latestTurn!,
- state: "completed",
- completedAt: "2026-04-01T00:00:03.000Z",
- },
- new Set([turnId]),
- new Set(),
- );
- expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({
- summary: settledDisplayName,
- summaryToolIcon: icon,
- live: false,
- });
- }
- },
- );
-
- it("retains Claude MCP metadata behind friendly row and running labels", () => {
- const turnId = TurnId.make("turn-claude-mcp");
- const toolData = {
- toolName: "mcp__t3-code__preview_click",
- input: { locator: { role: "button", name: "Continue" } },
- result: { content: "Clicked Continue" },
+ it("keeps opening and final assistant messages around the first hidden work", () => {
+ const opening = {
+ ...assistantMessage("2026-06-20T00:00:01.500Z"),
+ id: TurnItemId.make("item-opening"),
+ messageId: MessageId.make("message-opening"),
+ text: "I will check the deployment configuration.",
+ };
+ const middle = {
+ ...assistantMessage("2026-06-20T00:00:02.500Z"),
+ id: TurnItemId.make("item-middle"),
+ messageId: MessageId.make("message-middle"),
+ text: "The configuration is valid; checking the build next.",
+ };
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(opening, 1),
+ projected(command(), 2),
+ projected(middle, 3),
+ projected(assistantMessage(), 4),
+ ]);
+ const latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
};
- const detail = "Click Continue";
- const thread = makeThread({
- id: ThreadId.make("thread-claude-mcp"),
- projectId: ProjectId.make("project-1"),
- title: "Claude MCP labels",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("claude-mcp-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "MCP tool call completed",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- title: "MCP tool call",
- itemType: "mcp_tool_call",
- status: "completed",
- detail,
- data: toolData,
- },
- }),
- ],
- });
- const feed = buildThreadFeed(thread);
- const group = feed[0];
- expect(group).toMatchObject({
- type: "activity-group",
- activities: [
- {
- summary: "Clicked in the preview browser",
- detail,
- workEntry: { label: "MCP tool call completed", toolTitle: "MCP tool call" },
- },
- ],
+ const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
+ expect(collapsed.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "message-opening",
+ "run-fold:run-1",
+ "message-assistant",
+ ]);
+ expect(collapsed[1]).toMatchObject({ message: { text: opening.text } });
+ expect(collapsed[2]).toMatchObject({
+ type: "run-fold",
+ createdAt: "2026-06-20T00:00:02.000Z",
+ label: "Worked for 2.0s",
});
- if (!group || group.type !== "activity-group") return;
- const activity = group.activities[0]!;
- const fullDetail = `MCP call\n${JSON.stringify(toolData, null, 2)}\n\n${detail}`;
- expect(activity.workEntry.toolData).toBe(toolData);
- expect(activity.getFullDetail()).toBe(fullDetail);
- expect(activity.getCopyText()).toBe(`MCP tool call\n${detail}\n${fullDetail}`);
- expect(
- deriveThreadFeedPresentation(
- feed,
- thread.latestTurn,
- new Set(),
- new Set(),
- thread.latestTurn!.startedAt,
- ),
- ).toMatchObject([
- {
- type: "work-toggle",
- summary: "Clicking in the preview browser",
- summaryToolIcon: "browser",
- live: true,
- },
+
+ const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
+ expect(expanded.map((entry) => entry.type)).toEqual([
+ "message",
+ "message",
+ "run-fold",
+ "work-toggle",
+ "message",
+ "message",
]);
+ expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } });
});
- it.each([
- {
- status: "completed",
- displayName: "Clicked in the preview browser",
- liveDisplayName: "Clicking in the preview browser",
- detail: "Clicked Continue",
- hasFailure: false,
- },
- {
- status: "failed",
- displayName: "Failed to click in the preview browser",
- liveDisplayName: "Failed to click in the preview browser",
- detail: "Timed out waiting for Continue",
- hasFailure: true,
- },
- ])(
- "uses the browser call label once its action settles as $status",
- ({ status, displayName, liveDisplayName, detail, hasFailure }) => {
- const turnId = TurnId.make("turn-preview-lifecycle");
- const toolCallId = "preview-click";
- const groupId = `work-group:tool:${turnId}:${toolCallId}`;
- const toolData = {
- server: "t3-code",
- tool: "preview_click",
- arguments: { locator: { role: "button", name: "Continue" } },
- };
- const thread = makeThread({
- id: ThreadId.make("thread-preview-lifecycle"),
- projectId: ProjectId.make("project-1"),
- title: "Browser tool lifecycle",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("preview-click-started"),
- kind: "tool.updated",
- tone: "tool",
- summary: "MCP tool call",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- title: "MCP tool call",
- itemType: "mcp_tool_call",
- status: "inProgress",
- toolCallId,
- data: { item: toolData },
- },
- }),
- ],
- });
- const present = (currentThread: OrchestrationThread) =>
- deriveThreadFeedPresentation(
- buildThreadFeed(currentThread),
- currentThread.latestTurn,
- new Set([turnId]),
- new Set([groupId]),
- currentThread.latestTurn?.state === "running" ? currentThread.latestTurn.startedAt : null,
- );
-
- expect(present(thread)).toMatchObject([
+ it("does not fold a response that only has opening and final messages", () => {
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(
{
- type: "work-toggle",
- groupId,
- hiddenCount: 1,
- expanded: true,
- summary: "Clicking in the preview browser",
- summaryToolIcon: "browser",
- live: true,
- shimmer: true,
+ ...assistantMessage("2026-06-20T00:00:02.000Z"),
+ id: TurnItemId.make("item-opening"),
+ messageId: MessageId.make("message-opening"),
+ text: "The result is ready.",
},
- {
- type: "activity-group",
- id: `work-details:${groupId}`,
- activities: [
- {
- id: "preview-click-started",
- summary: "Clicking in the preview browser",
- lifecycleStatus: "inProgress",
- live: true,
- },
- ],
- },
- ]);
+ 1,
+ ),
+ projected(assistantMessage(), 2),
+ ]);
- const terminalThread = {
- ...thread,
- activities: [
- ...thread.activities,
- makeActivity({
- id: EventId.make("preview-click-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "MCP tool call completed",
- createdAt: "2026-04-01T00:00:03.000Z",
- turnId,
- payload: { itemType: "mcp_tool_call", toolCallId, status, detail },
- }),
- ],
- };
- const terminalRows = present(terminalThread);
- expect(terminalRows).toMatchObject([
- {
- type: "work-toggle",
- groupId,
- hiddenCount: 1,
- expanded: true,
- summary: liveDisplayName,
- summaryToolIcon: "browser",
- hasFailure,
- live: true,
- // A successful trailing call keeps shining; a failure hands off to "Thinking".
- shimmer: !hasFailure,
- },
- {
- type: "activity-group",
- id: `work-details:${groupId}`,
- activities: [
- {
- id: "preview-click-started",
- summary: displayName,
- lifecycleStatus: status,
- live: false,
- },
- ],
- },
- ...(hasFailure ? [{ type: "thinking", turnId }] : []),
- ]);
- const terminalGroup = terminalRows[1];
- if (terminalGroup?.type !== "activity-group") return;
- const activity = terminalGroup.activities[0]!;
- const fullDetail = `MCP call\n${JSON.stringify(toolData, null, 2)}\n\n${detail}`;
- expect(activity.workEntry.toolData).toBe(toolData);
- expect(activity.getFullDetail()).toBe(fullDetail);
- expect(activity.getCopyText()).toBe(`MCP tool call\n${detail}\n${fullDetail}`);
-
- const settledRows = present({
- ...terminalThread,
- latestTurn: {
- ...thread.latestTurn!,
- state: "completed",
- completedAt: "2026-04-01T00:00:04.000Z",
- },
- });
- expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({
- groupId,
- hiddenCount: 1,
- expanded: true,
- summary: displayName,
- summaryKind: "browser",
- hasFailure,
- live: false,
- });
- expect(settledRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: `work-details:${groupId}`,
- activities: [{ id: "preview-click-started", summary: displayName, live: false }],
- });
- },
- );
+ const presented = deriveThreadFeedPresentation(feed, null, new Set());
+ expect(presented.map((entry) => entry.id)).toEqual([
+ "message-user",
+ "message-opening",
+ "message-assistant",
+ ]);
+ });
- it.each([
- [0, "Used browser 3 times", "browser"],
- [2, "Ran 2 commands and used browser 3 times", "mixed"],
- ] as const)(
- "separates browser counts from %s completed commands",
- (commandCount, summary, summaryKind) => {
- const thread = makeThread({
- id: ThreadId.make("thread-browser-counts"),
- projectId: ProjectId.make("project-1"),
- title: "Browser group counts",
- activities: Array.from({ length: commandCount + 3 }, (_, index) =>
- makeActivity({
- id: EventId.make(`browser-count-${index}`),
- createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(),
- kind: "tool.completed",
- tone: "tool",
- summary: index < commandCount ? "Ran command" : "MCP tool call",
- payload: {
- toolCallId: `browser-count-${index}`,
- status: "completed",
- ...(index < commandCount
- ? {
- itemType: "command_execution",
- data: { item: { command: "/bin/bash -lc 'vp test run'" } },
- }
- : {
- itemType: "mcp_tool_call",
- data: { item: { server: "t3-code", tool: "preview_click" } },
- }),
- },
- }),
- ),
- });
- expect(
- deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set(), new Set()),
- ).toMatchObject([{ type: "work-toggle", summary, summaryKind, live: false }]);
- },
- );
+ it("folds subagents while keeping created-thread and fork cards visible", () => {
+ const { providerThreadId: _providerThreadId, ...forkBase } = base(
+ "item-fork",
+ "2026-06-20T00:00:02.000Z",
+ 2,
+ );
+ const resourceItems = [
+ {
+ ...base("item-subagent", "2026-06-20T00:00:01.500Z", 1),
+ type: "subagent",
+ subagentId: NodeId.make("child-agent"),
+ origin: "app_owned",
+ driver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ childThreadId: sourceThreadId,
+ prompt: "Inspect the deployment configuration",
+ result: "Configuration is valid",
+ },
+ {
+ ...forkBase,
+ type: "fork",
+ source: { type: "run", threadId, runId },
+ targetThreadId: sourceThreadId,
+ },
+ {
+ ...base("item-created-thread", "2026-06-20T00:00:04.000Z", 4),
+ type: "thread_created",
+ targetThreadId: sourceThreadId,
+ targetRunId: null,
+ targetProviderInstanceId: ProviderInstanceId.make("codex"),
+ targetModel: "gpt-5.4",
+ },
+ ] satisfies ReadonlyArray;
+ const projectedResources = [
+ projected(resourceItems[0]!, 1),
+ projected(resourceItems[1]!, 2),
+ projected(resourceItems[2]!, 4),
+ ];
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projectedResources[0]!,
+ projectedResources[1]!,
+ projected(command("2026-06-20T00:00:03.000Z"), 3),
+ projectedResources[2]!,
+ projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5),
+ ]);
- it("defers large tool output expansion until a work row is opened or copied", () => {
- let serializedToolOutputs = 0;
- const activities = Array.from({ length: 5_000 }, (_, index) =>
- makeActivity({
- id: EventId.make(`large-tool-${index}`),
- kind: "tool.completed",
- tone: "tool",
- summary: `Tool ${index}`,
- createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(),
- payload: {
- title: `Tool ${index}`,
- itemType: "mcp_tool_call",
- status: "completed",
- data: {
- item: {
- toJSON: () => {
- serializedToolOutputs += 1;
- return { output: "x".repeat(32_768) };
- },
- },
- },
- },
- }),
- );
- const thread = makeThread({
- id: ThreadId.make("thread-large-tools"),
- projectId: ProjectId.make("project-1"),
- title: "Large tools",
- activities,
+ const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
+ expect(collapsed.map((entry) => entry.type)).toEqual([
+ "message",
+ "run-fold",
+ "activity-group",
+ "activity-group",
+ "message",
+ ]);
+ expect(collapsed[1]).toMatchObject({
+ type: "run-fold",
+ createdAt: "2026-06-20T00:00:01.500Z",
});
+ expect(
+ collapsed.flatMap((entry) =>
+ entry.type === "activity-group"
+ ? entry.activities.map((activity) => activity.projectedItem)
+ : [],
+ ),
+ ).toEqual(projectedResources.slice(1));
+ const expanded = deriveThreadFeedPresentation(feed, null, new Set([runId]));
+ expect(
+ expanded.some(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities.some((activity) => activity.projectedItem.item.type === "subagent"),
+ ),
+ ).toBe(true);
+ });
- const feed = buildThreadFeed(thread);
- expect(serializedToolOutputs).toBe(0);
+ it("folds settled V2 run work while keeping the terminal assistant message visible", () => {
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(command(), 1),
+ projected(assistantMessage(), 2),
+ ]);
+ const latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
+ };
- const group = feed[0];
- expect(group).toMatchObject({ type: "activity-group" });
- if (!group || group.type !== "activity-group") {
- return;
- }
+ const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
+ expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
- expect(group.activities).toHaveLength(5_000);
- const expanded = deriveThreadFeedPresentation(
+ const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
+ expect(expanded.map((entry) => entry.type)).toEqual([
+ "message",
+ "run-fold",
+ "work-toggle",
+ "message",
+ ]);
+ });
+
+ it("keeps an active run expanded and detects failures from completed command output", () => {
+ const failedCommand: OrchestrationV2TurnItem = {
+ ...command(),
+ output: "sh: missing-command: command not found",
+ };
+ const feed = buildThreadFeed([projected(userMessage(), 0), projected(failedCommand, 1)]);
+ const presented = deriveThreadFeedPresentation(
feed,
- null,
+ {
+ runId,
+ status: "running",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ },
new Set(),
- new Set(["work-group:large-tool-0"]),
);
- expect(expanded).toHaveLength(2);
- expect(expanded[1]).toMatchObject({
- type: "activity-group",
- id: "work-details:work-group:large-tool-0",
+
+ expect(presented.some((entry) => entry.type === "run-fold")).toBe(false);
+ expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({
+ summary: "vp check",
+ hiddenCount: 1,
+ hasFailure: true,
+ live: false,
});
- if (expanded[1]?.type === "activity-group") {
- expect(expanded[1].activities).toHaveLength(5_000);
- expect(expanded[1].activities[0]?.getFullDetail).toBe(group.activities[0]?.getFullDetail);
- }
- expect(serializedToolOutputs).toBe(0);
- expect(group.activities[0]?.getFullDetail()).toContain('"output"');
- expect(serializedToolOutputs).toBe(1);
- expect(group.activities[0]?.getCopyText()).toContain('"output"');
- expect(serializedToolOutputs).toBe(1);
});
- it("keeps the first and terminal assistant messages visible around settled work", () => {
- const turnId = TurnId.make("turn-1");
- const thread = makeThread({
- id: ThreadId.make("thread-3"),
- projectId: ProjectId.make("project-1"),
- title: "Folded work",
- latestTurn: {
- turnId,
- state: "completed",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: "2026-04-01T00:00:18.000Z",
- assistantMessageId: MessageId.make("assistant-final"),
- },
- messages: [
- {
- id: MessageId.make("assistant-first"),
- role: "assistant",
- text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:02.000Z",
- updatedAt: "2026-04-01T00:00:03.000Z",
- },
- {
- id: MessageId.make("assistant-final"),
- role: "assistant",
- text: "Done.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:17.000Z",
- updatedAt: "2026-04-01T00:00:18.000Z",
- },
- ],
- activities: [
- makeActivity({
- id: EventId.make("tool-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Read files",
- createdAt: "2026-04-01T00:00:05.000Z",
- turnId,
- payload: {
- title: "Read files",
- itemType: "file_read",
- status: "completed",
- },
- }),
- ],
- });
+ it("folds each run of a provider-native subagent thread like a normal turn", () => {
+ // A Claude subagent's child thread, as projected: no runs, and a user
+ // prompt for the launch and for a SendMessage resume.
+ const runless = (item: T, id: string, ordinal: number) => ({
+ ...item,
+ id: TurnItemId.make(id),
+ runId: null,
+ ordinal,
+ });
+ const prompt = (id: string, ordinal: number, at: string) =>
+ runless(
+ { ...userMessage(at), messageId: MessageId.make(id), creationSource: "provider" as const },
+ id,
+ ordinal,
+ );
+ const answer = (id: string, ordinal: number, at: string) =>
+ runless({ ...assistantMessage(at), messageId: MessageId.make(id) }, id, ordinal);
+ const { exitCode: _exitCode, ...completedCommand } = command("2026-06-20T00:01:17.000Z");
+ const feed = (resumeRunning: boolean) =>
+ buildThreadFeed(
+ [
+ prompt("launch", 1, "2026-06-20T00:00:00.000Z"),
+ runless(command("2026-06-20T00:00:04.000Z"), "launch-ls", 2),
+ answer("launch-answer", 3, "2026-06-20T00:00:08.000Z"),
+ prompt("resume", 4, "2026-06-20T00:01:12.000Z"),
+ resumeRunning
+ ? runless(
+ { ...completedCommand, status: "running", completedAt: null, output: "" },
+ "resume-ls",
+ 5,
+ )
+ : runless(command("2026-06-20T00:01:17.000Z"), "resume-ls", 5),
+ ...(resumeRunning ? [] : [answer("resume-answer", 6, "2026-06-20T00:01:20.000Z")]),
+ ].map((item, position) => projected(item, position)),
+ );
+ const shape = (entries: ReadonlyArray) =>
+ entries.map((entry) =>
+ entry.type === "run-fold"
+ ? `fold:${entry.label}`
+ : entry.type === "message"
+ ? `${entry.message.role}:${entry.message.id}`
+ : entry.type,
+ );
- const feed = buildThreadFeed(thread);
- const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
- expect(collapsed.map((entry) => entry.id)).toEqual([
- "assistant-first",
- "turn-fold:turn-1",
- "assistant-final",
+ const settled = deriveThreadFeedPresentation(feed(false), null, new Set());
+ expect(shape(settled)).toEqual([
+ "user:launch",
+ "fold:Worked for 8.0s",
+ "assistant:launch-answer",
+ "user:resume",
+ "fold:Worked for 8.0s",
+ "assistant:resume-answer",
+ ]);
+ const launchFold = settled.find((entry) => entry.type === "run-fold");
+ if (launchFold?.type !== "run-fold") throw new Error("Expected the launch fold");
+ expect(
+ shape(deriveThreadFeedPresentation(feed(false), null, new Set([launchFold.runId]))),
+ ).toEqual([
+ "user:launch",
+ "fold:Worked for 8.0s",
+ "work-toggle",
+ "assistant:launch-answer",
+ "user:resume",
+ "fold:Worked for 8.0s",
+ "assistant:resume-answer",
]);
- expect(collapsed[1]).toMatchObject({
- type: "turn-fold",
- label: "Worked for 17s",
- expanded: false,
- });
- const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
- expect(expanded.map((entry) => entry.id)).toEqual([
- "assistant-first",
- "turn-fold:turn-1",
- "work-toggle:work-group:tool-completed",
- "assistant-final",
+ // While the resume runs, only the settled launch folds.
+ expect(
+ shape(
+ deriveThreadFeedPresentation(
+ feed(true),
+ null,
+ new Set(),
+ new Set(),
+ "2026-06-20T00:01:12.000Z",
+ true,
+ ),
+ ),
+ ).toEqual([
+ "user:launch",
+ "fold:Worked for 8.0s",
+ "assistant:launch-answer",
+ "user:resume",
+ "work-toggle",
+ ]);
+ });
+
+ it("keeps a provider-native subagent's runless tool call live while it works", () => {
+ const startedAt = "2026-06-20T00:00:01.000Z";
+ const { exitCode: _exitCode, ...completedCommand } = command();
+ const runningCommand: OrchestrationV2TurnItem = {
+ ...completedCommand,
+ runId: null,
+ status: "running",
+ completedAt: null,
+ output: "",
+ };
+ const feed = buildThreadFeed([
+ projected({ ...userMessage(), runId: null }, 0),
+ projected(runningCommand, 1),
]);
- const interrupted = deriveThreadFeedPresentation(
+ const presented = deriveThreadFeedPresentation(
feed,
- { ...thread.latestTurn!, state: "interrupted", completedAt: "2026-04-01T00:00:20.000Z" },
- new Set(),
- );
- expect(interrupted[1]).toMatchObject({
- type: "turn-fold",
- label: "You stopped after 19s",
- expanded: false,
- });
- const retimed = deriveThreadFeedPresentation(
- buildThreadFeed({
- ...thread,
- messages: [
- thread.messages[0]!,
- { ...thread.messages[1]!, updatedAt: "2026-04-01T00:00:25.000Z" },
- ],
- }),
null,
new Set(),
+ new Set(),
+ startedAt,
+ true,
);
- expect(retimed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 23s" });
- expect(collapsed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 17s" });
- });
-
- it("folds assistant messages between the first and terminal messages", () => {
- const turnId = TurnId.make("turn-1");
- const thread = makeThread({
- id: ThreadId.make("thread-middle-message"),
- projectId: ProjectId.make("project-1"),
- title: "Bounded narration",
- latestTurn: {
- turnId,
- state: "completed",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: "2026-04-01T00:00:06.000Z",
- assistantMessageId: MessageId.make("assistant-final"),
- },
- messages: [
- {
- id: MessageId.make("assistant-first"),
- role: "assistant",
- text: "The main result is ready.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:01.000Z",
- updatedAt: "2026-04-01T00:00:02.000Z",
- },
- {
- id: MessageId.make("assistant-middle"),
- role: "assistant",
- text: "I am checking one more detail.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:03.000Z",
- updatedAt: "2026-04-01T00:00:04.000Z",
- },
- {
- id: MessageId.make("assistant-final"),
- role: "assistant",
- text: "Verification finished.",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:05.000Z",
- updatedAt: "2026-04-01T00:00:06.000Z",
- },
- ],
+ expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({
+ summary: "Running vp",
+ live: true,
+ shimmer: true,
});
+ expect(presented.some((entry) => entry.type === "thinking")).toBe(false);
+ });
- const feed = buildThreadFeed(thread);
- const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
-
- expect(rows.map((entry) => entry.id)).toEqual([
- "assistant-first",
- "turn-fold:turn-1",
- "assistant-final",
+ it("keeps a runless tail settled while a normal thread waits for its sent run", () => {
+ // Right after a send the local clock runs before the server creates the
+ // run, and the latest run may still be queued: neither is runless work.
+ const startedAt = "2026-06-20T00:00:05.000Z";
+ const feed = buildThreadFeed([
+ projected({ ...userMessage(), runId: null }, 0),
+ projected({ ...command(), runId: null }, 1),
]);
+ for (const latestRun of [
+ null,
+ { runId, status: "queued" as const, startedAt: null, completedAt: null },
+ ]) {
+ const presented = deriveThreadFeedPresentation(
+ feed,
+ latestRun,
+ new Set(),
+ new Set(),
+ startedAt,
+ );
+ const toggle = presented.find((entry) => entry.type === "work-toggle");
+ expect(toggle).toMatchObject({ live: false, shimmer: false });
+ expect(presented.at(-1)?.type).toBe("thinking");
+ }
});
- it("measures a steer-superseded turn from its user boundary through trailing work", () => {
- const firstTurnId = TurnId.make("turn-1");
- const secondTurnId = TurnId.make("turn-2");
- const thread = makeThread({
- id: ThreadId.make("thread-steered"),
- projectId: ProjectId.make("project-1"),
- title: "Steered work",
- latestTurn: {
- turnId: secondTurnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:14.000Z",
- startedAt: "2026-04-01T00:00:14.000Z",
- completedAt: null,
- assistantMessageId: MessageId.make("assistant-next"),
- },
- messages: [
- {
- id: MessageId.make("user-1"),
- role: "user",
- text: "Do it once more.",
- turnId: null,
- streaming: false,
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- },
- {
- id: MessageId.make("assistant-commentary"),
- role: "assistant",
- text: "Kicking off call 1.",
- turnId: firstTurnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:09.000Z",
- updatedAt: "2026-04-01T00:00:09.000Z",
- },
- {
- id: MessageId.make("user-2"),
- role: "user",
- text: "Actually do 15.",
- turnId: null,
- streaming: false,
- createdAt: "2026-04-01T00:00:14.000Z",
- updatedAt: "2026-04-01T00:00:14.000Z",
- },
- {
- id: MessageId.make("assistant-next"),
- role: "assistant",
- text: "One down - adjusting.",
- turnId: secondTurnId,
- streaming: true,
- createdAt: "2026-04-01T00:00:17.000Z",
- updatedAt: "2026-04-01T00:00:17.000Z",
- },
- ],
- activities: [
- makeActivity({
- id: EventId.make("work-1"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Ran command",
- createdAt: "2026-04-01T00:00:12.000Z",
- turnId: firstTurnId,
- payload: {
- title: "Ran command",
- itemType: "command_execution",
- status: "completed",
- },
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
- expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({
- turnId: firstTurnId,
- label: "Worked for 12s",
- });
+ it("waits for workspace preparation before showing provider activity", () => {
+ const startedAt = "2026-04-01T00:00:01.000Z";
+ const run = { runId, status: "preparing" as const, startedAt: null, completedAt: null };
+ expect(deriveThreadFeedPresentation([], run, new Set(), new Set(), startedAt)).toEqual([]);
+ expect(
+ deriveThreadFeedPresentation(
+ [],
+ { ...run, status: "running", startedAt },
+ new Set(),
+ new Set(),
+ startedAt,
+ ),
+ ).toEqual([{ type: "thinking", id: "live-activity-row", createdAt: startedAt, runId }]);
});
- it("keeps an active turn expanded and classifies error-shaped tool output", () => {
- const turnId = TurnId.make("turn-running");
- const thread = makeThread({
- id: ThreadId.make("thread-4"),
- projectId: ProjectId.make("project-1"),
- title: "Running work",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("tool-succeeded"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Run command",
- createdAt: "2026-04-01T00:00:04.000Z",
- turnId,
- payload: {
- title: "Run command",
- itemType: "command_execution",
- detail: "done",
- status: "completed",
- },
- }),
- makeActivity({
- id: EventId.make("tool-failed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Run command",
- createdAt: "2026-04-01T00:00:05.000Z",
- turnId,
- payload: {
- title: "Run command",
- itemType: "command_execution",
- detail: "zsh: command not found: nope",
- status: "completed",
- },
- }),
- ],
- });
+ it("uses a stable Thinking row while work has started without a projected item", () => {
+ const startedAt = "2026-04-01T00:00:01.000Z";
+ const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt);
- const feed = buildThreadFeed(thread);
- expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toMatchObject([
- {
- type: "work-toggle",
- summary: "Ran 2 commands",
- hiddenCount: 2,
- hasFailure: true,
- },
+ expect(presented).toEqual([
+ { type: "thinking", id: "live-activity-row", createdAt: startedAt, runId: null },
]);
- expect(feed[0]).toMatchObject({
- type: "activity-group",
- activities: [{ status: "success" }, { status: "failure" }],
- });
- const expanded = deriveThreadFeedPresentation(
- feed,
- thread.latestTurn,
- new Set(),
- new Set(["work-group:tool-succeeded"]),
+ expect(deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt)[0]).toBe(
+ presented[0],
);
- expect(expanded.map((entry) => entry.id)).toEqual([
- "work-toggle:work-group:tool-succeeded",
- "work-details:work-group:tool-succeeded",
- ]);
- expect(expanded[1]).toMatchObject({
- type: "activity-group",
- activities: [
- { id: "tool-succeeded", status: "success", groupedToolDetail: true },
- { id: "tool-failed", status: "failure", groupedToolDetail: true },
- ],
- });
});
it("keeps expanded work in one group with stable row identities", () => {
@@ -2084,1548 +1173,798 @@ describe("buildThreadFeed", () => {
id: string,
createdAt: string,
status: ThreadFeedActivity["status"] = "success",
- toolSurface?: "browser" | "computer",
- toolIcon?: import("@t3tools/contracts").ToolActivityIcon,
): ThreadFeedActivity => ({
id,
createdAt,
- turnId: null,
+ runId: null,
+ attemptId: null,
summary: `Tool ${id}`,
detail: null,
canExpand: false,
getFullDetail: () => null,
getCopyText: () => id,
icon: "command",
+ logo: null,
toolLike: true,
+ prominent: false,
status,
+ lifecycleStatus: status === "neutral" ? "inProgress" : "completed",
workEntry: {
id,
createdAt,
- turnId: null,
label: `Tool ${id}`,
- command: `command ${id}`,
tone: "tool",
- ...(toolSurface ? { toolSurface } : {}),
- ...(toolIcon ? { toolIcon } : {}),
+ command: "vp check",
+ itemType: "command_execution",
+ toolLifecycleStatus: status === "neutral" ? "inProgress" : "completed",
},
+ projectedItem: projected(command(createdAt), 0),
});
const feed: ThreadFeedEntry[] = [
{
type: "activity-group",
id: "work-group-1",
createdAt: "2026-04-01T00:00:01.000Z",
- turnId: null,
+ runId: null,
activities: [
- activity("activity-1", "2026-04-01T00:00:01.000Z"),
- activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"),
- activity("activity-2", "2026-04-01T00:00:03.000Z", "success", "browser"),
- activity("activity-3", "2026-04-01T00:00:04.000Z", "success", "computer", {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- }),
+ activity("activity-neutral", "2026-04-01T00:00:01.000Z", "neutral"),
+ activity("activity-1", "2026-04-01T00:00:02.000Z"),
+ activity("activity-2", "2026-04-01T00:00:03.000Z"),
+ activity("activity-3", "2026-04-01T00:00:04.000Z"),
],
},
];
const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
- expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-1"]);
+ expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-neutral"]);
expect(collapsed[0]).toMatchObject({
type: "work-toggle",
- groupId: "work-group:activity-1",
+ groupId: "work-group:activity-neutral",
hiddenCount: 3,
expanded: false,
- summary: "Ran 3 commands",
- toolSurface: "computer",
- toolIcon: {
- _tag: "native-app",
- app: { _tag: "app-id", appId: "com.example.Editor" },
- },
- });
-
- const expanded = deriveThreadFeedPresentation(
- feed,
- null,
- new Set(),
- new Set(["work-group:activity-1"]),
- );
- expect(expanded.map((entry) => entry.id)).toEqual([
- "work-toggle:work-group:activity-1",
- "work-details:work-group:activity-1",
- ]);
- expect(expanded[0]).toMatchObject({
- type: "work-toggle",
- expanded: true,
- });
- expect(expanded[1]).toMatchObject({
- type: "activity-group",
- activities: [
- { id: "activity-1", groupedToolDetail: true, live: false },
- { id: "activity-2", groupedToolDetail: true, live: false },
- { id: "activity-3", groupedToolDetail: true, live: false },
- ],
- });
- const unchanged = deriveThreadFeedPresentation(
- feed,
- null,
- new Set(),
- new Set(["work-group:activity-1", "unrelated-group"]),
- );
- expect(unchanged[0]).toBe(expanded[0]);
- expect(unchanged[1]).toBe(expanded[1]);
- expect(deriveThreadFeedPresentation(feed, null, new Set())).toEqual(collapsed);
- });
-
- it.each(
- [
- "sudo -u root pnpm test",
- "/bin/zsh -lc 'sudo -u root pnpm test'",
- "/bin/bash -lc 'sudo -u root pnpm test'",
- ].flatMap((command) =>
- (
- [
- { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true },
- { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: true },
- { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false },
- { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false },
- { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false },
- ] as const
- ).map((state) => ({ command, ...state })),
- ),
- )(
- "keeps the command summary in sync with $lifecycleStatus: $command",
- ({ command, lifecycleStatus, summary, shimmer }) => {
- const turnId = TurnId.make("turn-live-tools");
- const activity = (
- id: string,
- status: ThreadFeedActivity["status"],
- lifecycleStatus: ThreadFeedActivity["lifecycleStatus"],
- tone: "tool" | "error" = "tool",
- command?: string,
- ): ThreadFeedActivity => ({
- id,
- createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
- turnId,
- summary: `Tool ${id}`,
- detail: lifecycleStatus === "stopped" ? "Exit code 130" : null,
- canExpand: false,
- getFullDetail: () => null,
- getCopyText: () => id,
- icon: "command",
- toolLike: true,
- status,
- lifecycleStatus,
- workEntry: {
- id,
- createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
- turnId,
- label: `Tool ${id}`,
- tone,
- toolLifecycleStatus: lifecycleStatus,
- ...(lifecycleStatus === "stopped" ? { detail: "Exit code 130" } : {}),
- ...(command ? { command, itemType: "command_execution" as const } : {}),
- },
- });
- const feed: ThreadFeedEntry[] = [
- {
- type: "activity-group",
- id: "activity-1",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- activities: [
- activity("activity-1", "success", "completed"),
- activity("activity-2", "failure", "failed", "error"),
- activity(
- "activity-3",
- lifecycleStatus === "inProgress"
- ? "neutral"
- : lifecycleStatus === "completed"
- ? "success"
- : "failure",
- lifecycleStatus,
- "tool",
- command,
- ),
- ...(lifecycleStatus === "inProgress"
- ? [activity("activity-4", "success", "completed", "tool", "printf done")]
- : []),
- ],
- },
- ];
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
-
- const rows = deriveThreadFeedPresentation(
- feed,
- latestTurn,
- new Set(),
- new Set(),
- latestTurn.startedAt,
- );
- // The shimmering row is the turn's live slot; once it stops shimmering
- // the slot belongs to "Thinking" and the group keeps its own identity.
- expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([
- ["work-toggle:work-group:activity-1", "work-toggle"],
- ["activity-2", "activity-group"],
- [shimmer ? "live-activity-row" : "work-live:work-group:activity-3", "work-toggle"],
- ]);
- expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([
- false,
- false,
- true,
- ]);
- expect(rows[2]).toMatchObject({
- summary,
- summaryKind: "command",
- live: true,
- shimmer,
- });
- expect(rows[0]).toMatchObject({ live: false, shimmer: false });
- // Exactly one live activity: the shimmering call, or "Thinking" once it fails.
- expect(rows.filter((entry) => entry.type === "thinking")).toHaveLength(shimmer ? 0 : 1);
- expect(rows.at(-1)?.type).toBe(shimmer ? "work-toggle" : "thinking");
-
- const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set());
- expect(stoppedRows.some((entry) => entry.type === "thinking")).toBe(false);
- expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
- { live: false, shimmer: false },
- {
- live: false,
- shimmer: false,
- summary: lifecycleStatus === "inProgress" ? "printf done" : command,
- },
- ]);
-
- const completedRows = deriveThreadFeedPresentation(
- feed,
- { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" },
- new Set([turnId]),
- new Set(),
- latestTurn.startedAt,
- );
- expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
- { live: false, shimmer: false },
- { live: false, shimmer: false },
- ]);
- },
- );
-
- it("groups ordered reasoning blocks, keeps the live slot, and restores the group after unfolding", () => {
- const turnId = TurnId.make("reasoning-group");
- const messages: OrchestrationThread["messages"] = [1, 2, 3, 4].map((second) => ({
- id: MessageId.make(`reasoning-${second}`),
- role: "reasoning",
- text: `**Step ${second}**\n\nCheck ${second}.`,
- turnId,
- streaming: second === 4,
- createdAt: `2026-04-01T00:00:0${second}.000Z`,
- updatedAt: `2026-04-01T00:00:0${second}.000Z`,
- }));
- const thread = makeThread({
- id: ThreadId.make("reasoning-group"),
- projectId: ProjectId.make("project-1"),
- title: "Reasoning",
- messages,
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- },
- });
- const feed = buildThreadFeed(thread);
- const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set(), new Set(), "now");
- expect(rows).toMatchObject([
- { type: "work-toggle", id: "live-activity-row", summary: "Thinking", hiddenCount: 4 },
- ]);
- expect(rows).toHaveLength(1);
- expect(
- deriveThreadFeedPresentation(feed, thread.latestTurn, new Set(), new Set(), "now")[0],
- ).toBe(rows[0]);
-
- const settledTurn = {
- ...thread.latestTurn!,
- state: "completed" as const,
- completedAt: "2026-04-01T00:00:06.000Z",
- };
- // Reasoning alone stays visible, including a streaming flag left behind on settlement.
- expect(deriveThreadFeedPresentation(feed, settledTurn, new Set())).toMatchObject([
- { type: "work-toggle", summary: "Thought (×4)", hiddenCount: 4 },
- ]);
- const completedMessages = messages.map((message) => ({ ...message, streaming: false }));
- const waiting = deriveThreadFeedPresentation(
- buildThreadFeed({ ...thread, messages: completedMessages }),
- thread.latestTurn,
- new Set(),
- new Set(),
- "now",
- );
- expect(waiting).toMatchObject([
- { type: "work-toggle", id: "live-activity-row", summary: "Thinking", hiddenCount: 4 },
- ]);
- const feedWithWork = buildThreadFeed({
- ...thread,
- activities: [
- makeActivity({
- id: EventId.make("reasoning-tool"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Read files",
- createdAt: "2026-04-01T00:00:05.000Z",
- turnId,
- payload: { itemType: "file_read", status: "completed" },
- }),
- ],
- });
- const toolRunning = deriveThreadFeedPresentation(
- feedWithWork,
- thread.latestTurn,
- new Set(),
- new Set(),
- "now",
- );
- expect(toolRunning).toMatchObject([
- { type: "work-toggle", id: "live-activity-row", shimmer: true, hiddenCount: 5 },
- ]);
- expect(toolRunning[0]).not.toMatchObject({ summary: "Thinking" });
- const nextThought = {
- ...messages[3]!,
- id: MessageId.make("reasoning-after-tool"),
- createdAt: "2026-04-01T00:00:06.000Z",
- updatedAt: "2026-04-01T00:00:06.000Z",
- };
- const reasoningAgainFeed: ThreadFeedEntry[] = [
- ...feedWithWork,
- {
- type: "message",
- id: nextThought.id,
- createdAt: nextThought.createdAt,
- message: nextThought,
- },
- ];
- const reasoningAgain = deriveThreadFeedPresentation(
- reasoningAgainFeed,
- thread.latestTurn,
- new Set(),
- new Set(),
- "now",
- );
- expect(reasoningAgain.filter((row) => row.id === "live-activity-row")).toMatchObject([
- { type: "work-toggle", summary: "Thinking", hiddenCount: 6 },
- ]);
- expect(reasoningAgain).toHaveLength(1);
- const expandedLive = deriveThreadFeedPresentation(
- reasoningAgainFeed,
- thread.latestTurn,
- new Set(),
- new Set([`activity-run:${messages[0]!.id}`]),
- "now",
- );
- expect(expandedLive.map((entry) => entry.type)).toEqual([
- "work-toggle",
- "message",
- "activity-group",
- "message",
- ]);
- expect(expandedLive[1]).toMatchObject({ reasoningMessages: messages });
- expect(expandedLive[3]).toMatchObject({ message: nextThought });
- expect(deriveThreadFeedPresentation(feedWithWork, settledTurn, new Set())).toMatchObject([
- { type: "turn-fold", expanded: false },
- ]);
- const reopened = deriveThreadFeedPresentation(
- feedWithWork,
- settledTurn,
- new Set([turnId]),
- new Set([`activity-run:${messages[0]!.id}`]),
- );
- expect(reopened.map((entry) => entry.type)).toEqual([
- "turn-fold",
- "work-toggle",
- "message",
- "activity-group",
- ]);
- expect(reopened[2]).toMatchObject({ id: messages[0]!.id, reasoningMessages: messages });
- const toolFirstFeed = feedWithWork.filter((entry) => entry.type === "activity-group");
- const toolFirst = deriveThreadFeedPresentation(
- toolFirstFeed,
- thread.latestTurn,
- new Set(),
- new Set(),
- "now",
- )[0];
- expect(toolFirst?.type).toBe("work-toggle");
- if (toolFirst?.type !== "work-toggle") return;
- const preservedExpansion = deriveThreadFeedPresentation(
- [...toolFirstFeed, reasoningAgainFeed.at(-1)!],
- thread.latestTurn,
+ summary: "Ran 3 commands",
+ });
+
+ const expanded = deriveThreadFeedPresentation(
+ feed,
+ null,
new Set(),
- new Set([toolFirst.groupId]),
- "now",
+ new Set(["work-group:activity-neutral"]),
);
- expect(preservedExpansion.map((entry) => entry.type)).toEqual([
- "work-toggle",
- "activity-group",
- "message",
+ expect(expanded.map((entry) => entry.id)).toEqual([
+ "work-toggle:work-group:activity-neutral",
+ "work-details:work-group:activity-neutral",
]);
- expect(preservedExpansion[0]).toMatchObject({
- id: "live-activity-row",
- groupId: toolFirst.groupId,
+ expect(expanded[0]).toMatchObject({
+ type: "work-toggle",
expanded: true,
- summary: "Thinking",
});
- const strandedToolFeed = buildThreadFeed({
- ...thread,
- messages: [],
+ expect(expanded[1]).toMatchObject({
+ type: "activity-group",
activities: [
- makeActivity({
- id: EventId.make("stranded-tool"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Running command",
- createdAt: "2026-04-01T00:00:00.000Z",
- turnId,
- payload: {
- toolCallId: "stranded-tool",
- itemType: "command_execution",
- command: "sleep 60",
- status: "inProgress",
- },
- }),
+ { id: "activity-1", groupedToolDetail: true, live: false },
+ { id: "activity-2", groupedToolDetail: true, live: false },
+ { id: "activity-3", groupedToolDetail: true, live: false },
],
});
- const afterStrandedTool = deriveThreadFeedPresentation(
- [...strandedToolFeed, ...feedWithWork],
- thread.latestTurn,
- new Set(),
- new Set(),
- "now",
- );
- expect(afterStrandedTool).toMatchObject([
- { type: "work-toggle", id: "live-activity-row", summary: toolFirst.summary, hiddenCount: 6 },
- ]);
});
- it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)(
- "keeps thoughts in order across a %s in expanded activity history",
- (boundary) => {
- const turnId = TurnId.make("reasoning-boundary");
- const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({
- id: MessageId.make(`reasoning-${second}`),
- role: "reasoning",
- text: `Step ${second}`,
- turnId:
- boundary === "unknown-turn"
- ? null
- : boundary === "turn" && second === 3
- ? TurnId.make("other-turn")
- : turnId,
- streaming: false,
- createdAt: `2026-04-01T00:00:0${second}.000Z`,
- updatedAt: `2026-04-01T00:00:0${second}.000Z`,
- }));
- const thread = makeThread({
- id: ThreadId.make("reasoning-boundary"),
- projectId: ProjectId.make("project-1"),
- title: "Reasoning",
- messages:
- boundary === "assistant"
- ? [
- messages[0]!,
- {
- ...messages[0]!,
- id: MessageId.make("assistant-between"),
- role: "assistant",
- text: "Checking the next file.",
- createdAt: "2026-04-01T00:00:02.000Z",
- },
- messages[1]!,
- ]
- : messages,
- activities:
- boundary === "tool" || boundary === "failed-tool"
- ? [
- makeActivity({
- id: EventId.make("tool-between"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Read files",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- itemType: "file_read",
- status: boundary === "failed-tool" ? "failed" : "completed",
- },
- }),
- ]
- : [],
- });
- const rows = deriveThreadFeedPresentation(
- buildThreadFeed(thread),
- null,
- new Set([turnId]),
- new Set(messages.map((message) => `activity-run:${message.id}`)),
- );
- const reasoningRows = rows.filter(
- (entry) => entry.type === "message" && entry.message.role === "reasoning",
- );
- if (boundary === "failed-tool") {
- // A failed call stays inside the run instead of splitting it.
- expect(rows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
- { hasFailure: true, hiddenCount: 3 },
- ]);
- }
- expect(reasoningRows).toEqual(
- messages.map((message) => ({
- type: "message",
- id: message.id,
- createdAt: message.createdAt,
- message,
- })),
- );
- },
- );
+ it("retains Claude Read image previews without tool output", () => {
+ const item = {
+ ...base("image-read", "2026-06-20T00:00:04.000Z", 3),
+ type: "dynamic_tool" as const,
+ toolName: "Read",
+ input: { file_path: "/workspace/reference.png" },
+ viewedImagePath: "/workspace/reference.png",
+ } satisfies OrchestrationV2TurnItem;
+ const feed = buildThreadFeed([projected(item, 0)]);
+ const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
+ expect(activity?.workEntry.viewedImagePath).toBe("/workspace/reference.png");
+ });
- it("shows one Thinking row while a turn works without live tool activity", () => {
- const turnId = TurnId.make("turn-thinking");
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
+ it("pretty prints T3 MCP dynamic tool activities and attaches the product logo", () => {
+ const toolItem: OrchestrationV2TurnItem = {
+ ...base("item-t3-tool", "2026-06-20T00:00:04.000Z", 3),
+ type: "dynamic_tool",
+ toolName: "mcp__t3-code__t3_thread_read",
+ input: { threadId: "thread-child" },
+ output: { messages: [] },
};
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-thinking"),
- projectId: ProjectId.make("project-1"),
- title: "Thinking",
- latestTurn,
- messages: [
- {
- id: MessageId.make("user-1"),
- role: "user",
- text: "hello",
- turnId,
- streaming: false,
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- },
- ],
- }),
- );
- const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now");
- expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]);
- expect(rows[1]).toMatchObject({ id: "live-activity-row", createdAt: "now", turnId });
- // The row identity is stable across re-derivations so the list can reuse it.
- expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe(
- rows[1],
- );
- // Idle threads show no live activity.
- expect(
- deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), null).map(
- (entry) => entry.type,
- ),
- ).toEqual(["message"]);
+ const feed = buildThreadFeed([projected(toolItem, 0)]);
+ const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
+
+ expect(activity?.summary).toBe("Read a T3 thread");
+ expect(activity?.logo).toBe("t3-code");
+ expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread");
});
- it("keeps one live slot while calls fail and restart", () => {
- // Recorded from a Claude session whose Bash was broken: every call went
- // inProgress → failed within two seconds. Each transition used to insert
- // or remove a Thinking row under the group; now the same row id holds
- // the live call and then "Thinking", so the list updates it in place.
- const turnId = TurnId.make("turn-failing-calls");
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
+ it("uses the CUA action title in the mobile feed", () => {
+ const item: OrchestrationV2TurnItem = {
+ ...base("cua", "2026-09-23T20:20:00.000Z", 1),
+ type: "dynamic_tool",
+ toolName: "cua_repl.js",
+ input: { code: "await game.getAXStateAndScreenshot();", title: "Inspect Saga music screen" },
};
- const call = (n: number, status: "inProgress" | "failed") =>
- makeActivity({
- id: EventId.make(`call-${n}-${status}`),
- kind: status === "failed" ? "tool.completed" : "tool.updated",
- tone: "tool",
- summary: "Command run",
- createdAt: `2026-04-01T00:00:${String(n * 2 + (status === "failed" ? 1 : 0)).padStart(2, "0")}.000Z`,
- turnId,
- payload: {
- itemType: "command_execution",
- toolCallId: `call-${n}`,
- title: "Command run",
- status,
- detail: `Bash: ls ${n}`,
- },
- });
- const liveIds = (activities: ReadonlyArray>) =>
- deriveThreadFeedPresentation(
- buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-failing-calls"),
- projectId: ProjectId.make("project-1"),
- title: "Failing calls",
- latestTurn,
- activities,
- }),
- ),
- latestTurn,
- new Set(),
- new Set(),
- latestTurn.startedAt,
- ).map((row) => `${row.type}:${row.id}`);
-
- expect(liveIds([call(1, "inProgress")])).toEqual(["work-toggle:live-activity-row"]);
- expect(liveIds([call(1, "inProgress"), call(1, "failed")])).toEqual([
- "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1",
- "thinking:live-activity-row",
- ]);
- expect(liveIds([call(1, "inProgress"), call(1, "failed"), call(2, "inProgress")])).toEqual([
- "work-toggle:live-activity-row",
- ]);
- // A call whose end was never reported, in a run before an error row,
- // keeps its own identity: only the trailing run can hold the live slot.
- const errorRow = makeActivity({
- id: EventId.make("runtime-error"),
- kind: "runtime.error",
- tone: "error",
- summary: "Provider error",
- createdAt: "2026-04-01T00:00:02.500Z",
- turnId,
- payload: { message: "boom" },
- });
- expect(liveIds([call(1, "inProgress"), errorRow, call(2, "inProgress")])).toEqual([
- "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1",
- "activity-group:runtime-error",
- "work-toggle:live-activity-row",
- ]);
+ const feed = buildThreadFeed([projected(item, 0)]);
+ const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
+ expect(activity?.summary).toBe("Inspect Saga music screen");
});
- it("hands a settled tool run off to Thinking once assistant text streams after it", () => {
- const turnId = TurnId.make("turn-streaming-tail");
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-streaming-tail"),
- projectId: ProjectId.make("project-1"),
- title: "Streaming tail",
- latestTurn,
- messages: [
- {
- id: MessageId.make("assistant-1"),
- role: "assistant",
- text: "Here is what I found",
- turnId,
- streaming: true,
- createdAt: "2026-04-01T00:00:05.000Z",
- updatedAt: "2026-04-01T00:00:06.000Z",
- },
- ],
- activities: [
- makeActivity({
- id: EventId.make("read-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Read file",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: {
- itemType: "file_read",
- toolCallId: "read-1",
- title: "Read file",
- status: "completed",
- detail: "src/index.ts",
+ it("uses canonical T3 orchestration summaries in compact work groups", () => {
+ const rows = [
+ projected(command("2026-06-20T00:00:01.000Z"), 0),
+ ...["mcp__t3-code__t3_thread_send", "t3_code.t3_thread_send", "t3_thread_send"].map(
+ (toolName, index) =>
+ projected(
+ {
+ ...base(`item-send-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 2),
+ type: "dynamic_tool" as const,
+ toolName,
+ input: { threadId: `thread-${index}`, message: "Continue" },
+ output: { threadId: `thread-${index}`, messageId: `message-${index}` },
},
- }),
- ],
- }),
- );
+ index + 1,
+ ),
+ ),
+ projected(
+ {
+ ...command("2026-06-20T00:00:06.000Z"),
+ id: TurnItemId.make("item-command-2"),
+ ordinal: 6,
+ },
+ 4,
+ ),
+ ];
- const rows = deriveThreadFeedPresentation(
- feed,
- latestTurn,
+ const presented = deriveThreadFeedPresentation(
+ buildThreadFeed(rows),
+ { runId, status: "running", startedAt: null, completedAt: null },
new Set(),
- new Set(),
- latestTurn.startedAt,
);
- expect(rows.map((entry) => entry.type)).toEqual(["work-toggle", "message", "thinking"]);
- expect(rows[0]).toMatchObject({ live: false, shimmer: false });
- });
- it("preserves serialized shell wrappers with non-matching boundary quotes", () => {
- const turnId = TurnId.make("turn-serialized-shell-wrapper");
- const command =
- "/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\"";
- const thread = makeThread({
- id: ThreadId.make("thread-serialized-shell-wrapper"),
- projectId: ProjectId.make("project-1"),
- title: "Serialized shell wrapper",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
+ expect(presented).toMatchObject([
+ {
+ type: "work-toggle",
+ summary: "Ran 2 commands and sent messages to 3 threads",
+ hiddenCount: 5,
+ hasFailure: false,
},
- activities: [
- makeActivity({
- id: EventId.make("serialized-shell-wrapper"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Ran command",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- payload: {
- itemType: "command_execution",
- status: "inProgress",
- data: { item: { command } },
- },
- }),
- ],
- });
-
- const feed = buildThreadFeed(thread);
- expect(feed[0]).toMatchObject({
- type: "activity-group",
- activities: [{ workEntry: { command } }],
- });
- if (feed[0]?.type === "activity-group") {
- expect(feed[0].activities[0]?.workEntry.rawCommand).toBeUndefined();
- }
+ ]);
});
- it.each([
- ["inProgress", true],
- ["completed", false],
- ["failed", false],
- ["declined", false],
- ["stopped", false],
- ] as const)("respects the %s lifecycle of trailing task progress", (status, shimmer) => {
- const turnId = TurnId.make("turn-task-progress");
- const thread = makeThread({
- id: ThreadId.make("thread-task-progress"),
- projectId: ProjectId.make("project-1"),
- title: "Task lifecycle",
- latestTurn: {
- turnId,
- state: "running",
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:01.000Z",
- completedAt: null,
- assistantMessageId: null,
+ it("presents project calls and summarizes successful clones through the mobile feed", () => {
+ const items: OrchestrationV2TurnItem[] = [
+ {
+ ...base("list", "2026-09-19T00:00:01.000Z", 1),
+ type: "dynamic_tool",
+ title: "Custom provider title",
+ toolName: "T3-code.t3_project_list",
+ input: {},
+ output: { projects: [] },
},
- activities: [
- makeActivity({
- id: EventId.make("task-progress"),
- kind: "task.progress",
- summary: "Task progress",
- createdAt: "2026-04-01T00:00:02.000Z",
- turnId,
- payload: { taskId: "task-1", status },
- }),
- ],
+ {
+ ...base("clone", "2026-09-19T00:00:02.000Z", 2),
+ type: "dynamic_tool",
+ title: "Custom provider title",
+ toolName: "mcp__t3_code__t3_project_clone",
+ input: {},
+ output: { cwd: "/tmp/repo" },
+ },
+ {
+ ...base("failed-clone", "2026-09-19T00:00:03.000Z", 3),
+ type: "dynamic_tool",
+ title: "Custom provider title",
+ toolName: "t3_project_clone",
+ input: {},
+ output: { isError: true },
+ },
+ ];
+ const feed = buildThreadFeed(items.map((item, position) => projected(item, position)));
+ const activities = feed.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
+ expect(workEntryRowLabel(activities[0]!.workEntry)).toBe("Listed projects");
+ expect(workEntryRowLabel(activities[1]!.workEntry)).toBe("Cloned a repository");
+ expect(workEntryRowLabel(activities[2]!.workEntry)).toBe("Failed to clone a repository");
+ expect(activities.every((activity) => activity.logo === "t3-code")).toBe(true);
+ const presented = deriveThreadFeedPresentation(
+ feed,
+ { runId, status: "running", startedAt: null, completedAt: null },
+ new Set(),
+ );
+ expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({
+ summary: "Listed projects 1 time and cloned 1 repository",
+ hasFailure: true,
});
+ });
+});
- const rows = deriveThreadFeedPresentation(
- buildThreadFeed(thread),
- thread.latestTurn,
+describe("retained v2 feed presentation", () => {
+ it("retains unchanged rows while the assistant streams", () => {
+ const rows = [
+ projected(userMessage(), 0),
+ projected(command(), 1),
+ projected({ ...assistantMessage(), streaming: true }, 2),
+ ];
+ const latestRun = {
+ runId,
+ status: "running" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ };
+ const before = buildThreadFeed(rows);
+ const beforePresentation = deriveThreadFeedPresentation(
+ before,
+ latestRun,
+ new Set(),
+ new Set(),
+ latestRun.startedAt,
+ );
+ const after = buildThreadFeed([
+ rows[0]!,
+ rows[1]!,
+ projected(
+ { ...assistantMessage("2026-06-20T00:00:04.000Z"), text: "Still working", streaming: true },
+ 2,
+ ),
+ ]);
+ const afterPresentation = deriveThreadFeedPresentation(
+ after,
+ latestRun,
new Set(),
new Set(),
- thread.latestTurn!.startedAt,
+ latestRun.startedAt,
);
- expect(rows.some((entry) => entry.type === "work-toggle" && entry.shimmer)).toBe(shimmer);
+ expect(after[0]).toBe(before[0]);
+ expect(after[1]).toBe(before[1]);
+ expect(after[2]).not.toBe(before[2]);
+ expect(afterPresentation[0]).toBe(beforePresentation[0]);
+ expect(afterPresentation[1]).toBe(beforePresentation[1]);
});
- it("does not revive cached in-progress tools after work stops", () => {
- const turnId = TurnId.make("turn-stale-tool");
- const feed: ThreadFeedEntry[] = [
- {
- type: "activity-group",
- id: "stale-tool",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
+ it.each(["running", "completed", "interrupted"] as const)(
+ "uses the compaction row as the live activity only while %s",
+ (status) => {
+ const compact = projected(
+ {
+ ...base("compacted", "2026-06-20T00:00:02.000Z", 1),
+ type: "compaction",
+ status,
+ driver: null,
+ beforeTokenCount: 899_000,
+ ...(status === "completed" ? { afterTokenCount: 19_000 } : {}),
+ },
+ 1,
+ );
+ const latestRun = {
+ runId,
+ status: "running" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: null,
+ };
+ const rows = deriveThreadFeedPresentation(
+ buildThreadFeed([projected(userMessage(), 0), compact]),
+ latestRun,
+ new Set(),
+ new Set(),
+ latestRun.startedAt,
+ );
+ expect(rows.some((row) => row.type === "thinking")).toBe(status !== "running");
+ expect(rows.find((row) => row.type === "activity-group")).toMatchObject({
activities: [
{
- id: "stale-tool",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- summary: "Running tests",
- detail: null,
- canExpand: false,
- getFullDetail: () => null,
- getCopyText: () => "",
- icon: "command",
- toolLike: true,
- status: "neutral",
- lifecycleStatus: "inProgress",
- workEntry: {
- id: "stale-tool",
- createdAt: "2026-04-01T00:00:01.000Z",
- turnId,
- label: "Running tests",
- tone: "tool",
- toolLifecycleStatus: "inProgress",
- },
+ summary:
+ status === "running"
+ ? "Compacting context"
+ : status === "completed"
+ ? "Context compacted 899K → 19K tokens"
+ : "Context compacted",
},
],
- },
- ];
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
-
- expect(deriveThreadFeedPresentation(feed, latestTurn, new Set())).toEqual([]);
- expect(
- deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), latestTurn.startedAt),
- ).toMatchObject([{ type: "work-toggle", live: true, shimmer: true }]);
- });
+ });
+ },
+ );
- it("collapses interleaved tool lifecycles by call identity", () => {
- const turnId = TurnId.make("turn-parallel-tools");
- const toolActivity = (
- id: string,
- toolCallId: string,
- kind: "tool.updated" | "tool.completed",
- status: "inProgress" | "completed",
- detail: string,
- nestedId = false,
- ) =>
- makeActivity({
- id: EventId.make(id),
- kind,
- tone: "tool",
- summary: `Run ${toolCallId} command`,
- createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
- turnId,
- payload: {
- ...(nestedId ? { data: { toolCallId } } : { toolCallId }),
- itemType: "command_execution",
+ it.each(["running", "completed", "failed"] as const)(
+ "keeps a %s handoff separate from commands and visible through folds",
+ (status) => {
+ const handoff = projected(
+ {
+ ...base("handoff", "2026-06-20T00:00:02.000Z", 1),
+ type: "handoff",
status,
- detail,
+ contextHandoffId: ContextHandoffId.make("handoff"),
+ fromProviderThreadIds: [],
+ toProviderThreadId: ProviderThreadId.make("target"),
+ fromProviderInstanceIds: [ProviderInstanceId.make("codex")],
+ toProviderInstanceId: ProviderInstanceId.make("claudeAgent"),
+ strategy: "full_thread_summary",
+ summary: "Private full conversation summary",
},
- });
- const thread = makeThread({
- id: ThreadId.make("thread-parallel-tools"),
- projectId: ProjectId.make("project-1"),
- title: "Parallel tools",
- activities: [
- toolActivity("call-a-1", "call-a", "tool.updated", "inProgress", "starting"),
- toolActivity("call-b-2", "call-b", "tool.updated", "inProgress", "starting", true),
- toolActivity("call-a-3", "call-a", "tool.completed", "completed", "first output"),
- toolActivity("call-b-4", "call-b", "tool.completed", "completed", "second output", true),
- ],
- });
+ 1,
+ );
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ handoff,
+ projected(command("2026-06-20T00:00:03.000Z"), 2),
+ projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3),
+ ]);
+ for (const expanded of [new Set(), new Set([runId])]) {
+ const rows = deriveThreadFeedPresentation(feed, null, expanded);
+ const divider = rows.filter(
+ (entry) => entry.type === "activity-group" && isContextHandoffActivityGroup(entry),
+ );
+ expect(divider).toHaveLength(1);
+ expect(divider[0]).toMatchObject({ activities: [{ projectedItem: handoff }] });
+ }
+ const alone = deriveThreadFeedPresentation(
+ buildThreadFeed([projected(userMessage(), 0), handoff]),
+ null,
+ new Set(),
+ );
+ expect(alone.map((entry) => entry.type)).toEqual(["message", "activity-group"]);
+ },
+ );
- const feed = buildThreadFeed(thread);
- const activityGroup = feed.find((entry) => entry.type === "activity-group");
- expect(activityGroup).toMatchObject({
- type: "activity-group",
- activities: [
- { id: "call-a-1", lifecycleStatus: "completed", detail: "first output" },
- { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" },
- ],
- });
+ it("keeps a standalone compaction visible and folds it with other completed work", () => {
+ const compact = projected(
+ {
+ ...base("compacted", "2026-06-20T00:00:02.000Z", 1),
+ type: "compaction",
+ driver: null,
+ summary: "Shorter context",
+ },
+ 1,
+ );
+ const latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:04.000Z",
+ };
+ const onlyCompaction = deriveThreadFeedPresentation(
+ buildThreadFeed([projected(userMessage(), 0), compact]),
+ latestRun,
+ new Set(),
+ );
+ expect(onlyCompaction.map((entry) => entry.type)).toEqual(["message", "activity-group"]);
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ compact,
+ projected(command("2026-06-20T00:00:03.000Z"), 2),
+ projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3),
+ ]);
+ expect(
+ deriveThreadFeedPresentation(feed, latestRun, new Set()).map((entry) => entry.type),
+ ).toEqual(["message", "run-fold", "message"]);
+ const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
expect(
- deriveThreadFeedPresentation(feed, null, new Set([turnId])).find(
- (entry) => entry.type === "work-toggle",
+ expanded.find(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities[0]?.projectedItem.item.type === "compaction",
),
- ).toMatchObject({
- type: "work-toggle",
- hiddenCount: 2,
- summary: "Ran 2 commands",
- live: false,
- });
+ ).toMatchObject({ activities: [{ summary: "Context compacted" }] });
+ });
- const groupId = `work-group:tool:${turnId}:call-a`;
- const startedAt = "2026-04-01T00:00:00.000Z";
- const runningRows = deriveThreadFeedPresentation(
- buildThreadFeed({ ...thread, activities: thread.activities.slice(0, 2) }),
- { turnId, state: "running", startedAt, completedAt: null },
- new Set(),
- new Set([groupId]),
- startedAt,
- );
- expect(runningRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: `work-details:${groupId}`,
- activities: [
- { id: "call-a-1", lifecycleStatus: "inProgress", groupedToolDetail: true, live: false },
- { id: "call-b-2", lifecycleStatus: "inProgress", groupedToolDetail: true, live: true },
- ],
- });
+ it("retains assistant image attachments from the wire", () => {
+ const image = {
+ type: "image" as const,
+ id: "assistant-image",
+ name: "result.png",
+ mimeType: "image/png",
+ sizeBytes: 100,
+ };
+ const feed = buildThreadFeed([
+ projected({ ...assistantMessage(), text: "", attachments: [image] }, 0),
+ ]);
+ expect(feed).toMatchObject([
+ { type: "message", message: { role: "assistant", attachments: [image] } },
+ ]);
+ });
- const completedRows = deriveThreadFeedPresentation(
+ it("keeps native application icons and source identity in collapsed and expanded work", () => {
+ const icon = {
+ _tag: "native-app" as const,
+ app: { _tag: "app-id" as const, appId: "com.example.Editor" },
+ };
+ const source = {
+ key: "native-app:com.example.editor",
+ name: "Editor",
+ kind: "computer" as const,
+ icon,
+ };
+ const rows = [0, 1].map((index) =>
+ projected(
+ {
+ ...base(`native-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 1),
+ type: "dynamic_tool" as const,
+ toolName: "computer.click",
+ input: { x: index, y: 1 },
+ output: null,
+ toolSurface: "computer" as const,
+ toolIcon: icon,
+ toolSource: source,
+ },
+ index,
+ ),
+ );
+ const feed = buildThreadFeed(rows);
+ const latestRun = { runId, status: "running" as const, startedAt: null, completedAt: null };
+ const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
+ const toggle = collapsed[0];
+ if (toggle?.type !== "work-toggle") throw new Error("Expected a collapsed work group");
+ const presented = deriveThreadFeedPresentation(
feed,
- null,
- new Set([turnId]),
- new Set([groupId]),
+ latestRun,
+ new Set(),
+ new Set([toggle.groupId]),
);
- expect(completedRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: `work-details:${groupId}`,
- activities: [
- { id: "call-a-1", lifecycleStatus: "completed", groupedToolDetail: true, live: false },
- { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false },
- ],
- });
-
- const correctedFeed = buildThreadFeed({
- ...thread,
- activities: thread.activities.map((activity) =>
- activity.id === "call-a-3"
- ? {
- ...activity,
- tone: "error",
- payload: {
- toolCallId: "call-a",
- itemType: "command_execution",
- status: "failed",
- detail: "Corrected failure output",
- },
- }
- : activity,
- ),
+ expect(presented[0]).toMatchObject({
+ type: "work-toggle",
+ summary: "Used Editor",
+ toolSurface: "computer",
+ toolIcon: icon,
});
- const correctedGroup = correctedFeed.find((entry) => entry.type === "activity-group");
- expect(correctedGroup).toMatchObject({
+ expect(presented[1]).toMatchObject({
+ type: "activity-group",
activities: [
- { id: "call-a-1", lifecycleStatus: "failed", detail: "Corrected failure output" },
- { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" },
+ { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } },
+ { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } },
],
});
- expect(correctedGroup?.activities[0]?.getCopyText()).toContain("Corrected failure output");
- expect(activityGroup?.activities[0]?.getCopyText()).toContain("first output");
- const correctedRows = deriveThreadFeedPresentation(
- correctedFeed,
- null,
- new Set([turnId]),
- new Set([groupId]),
- );
- expect(correctedRows.find((entry) => entry.type === "activity-group")).toMatchObject({
- id: "call-a-1",
- activities: [{ status: "failure", workEntry: { tone: "error" } }],
- });
});
-});
-describe("quiet timeline: nested agents", () => {
- it.each(["task.updated", "task.progress"] as const)(
- "does not mark an ordinary task complete when it resumes through %s",
- (resumeKind) => {
- const thread = makeThread({
- id: ThreadId.make("resumed-agent"),
- projectId: ProjectId.make("project-1"),
- title: "Resumed agent",
- activities: (
- [
- ["task.progress", "running", "Review"],
- ["task.updated", "idle", "Task idle"],
- [resumeKind, "running", "Review resumed"],
- ] as const
- ).map(([kind, status, summary], index) =>
- makeActivity({
- id: EventId.make(`resumed-${index}`),
- kind,
- summary,
- createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,
- payload: {
- taskId: "agent-1",
- agentKind: "agent",
- title: "Reviewer",
- status,
- detail: summary,
- },
- }),
+ it.each([
+ ["failed", "Failed to click in the preview browser", true],
+ ["cancelled", "Stopped clicking in the preview browser", false],
+ ] as const)(
+ "keeps %s calls terminal while the parent run remains live",
+ (status, summary, hasFailure) => {
+ const feed = buildThreadFeed([
+ projected(
+ {
+ ...base("preview-click", "2026-06-20T00:00:02.000Z", 1),
+ type: "dynamic_tool",
+ status,
+ toolName: "mcp__t3-code__preview_click",
+ input: { element: "button" },
+ output: null,
+ },
+ 0,
),
- });
- const rows = buildThreadFeed(thread).flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities : [],
- );
- // The agent folds into its spawn batch, which stays live after a resume.
- expect(rows).toMatchObject([
- {
- lifecycleStatus: "inProgress",
- summary: "Kicked off 1 subagent · 1 working",
- workEntry: { agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] } },
- },
]);
+ const rows = deriveThreadFeedPresentation(
+ feed,
+ { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null },
+ new Set(),
+ new Set(),
+ "2026-06-20T00:00:01.000Z",
+ );
+ expect(rows[0]).toMatchObject({ type: "work-toggle", summary, hasFailure, shimmer: false });
},
);
- it("folds a turn's direct spawns into one batch row that tracks their states", () => {
- const turnId = TurnId.make("turn-spawn");
- const agent = (
- id: string,
- kind: "task.started" | "task.progress" | "task.completed" | "task.updated",
- taskId: string,
- status: string,
- seconds: number,
- extra: Record = {},
- ) =>
- makeActivity({
- id: EventId.make(id),
- kind,
- summary: `${taskId} ${status}`,
- createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
- turnId,
- payload: {
- taskId,
- agentKind: "agent",
- taskType: "local_agent",
- title: `Agent ${taskId}`,
- status,
- ...extra,
- },
- });
- const shell = makeActivity({
- id: EventId.make("shell-1"),
- kind: "task.completed",
- summary: "Task completed",
- createdAt: "2026-04-01T00:00:05.000Z",
- turnId,
- payload: {
- taskId: "sh-1",
- agentKind: "background",
- taskType: "local_bash",
- status: "completed",
- title: "Run tests",
- detail: "Run tests",
- },
- });
- const activities = [
- agent("a-start", "task.started", "a", "running", 1),
- agent("b-start", "task.started", "b", "running", 2),
- agent("a-progress", "task.progress", "a", "running", 3, { detail: "Reading files" }),
- shell,
- agent("b-progress", "task.progress", "b", "running", 6, { detail: "Grepping" }),
- ];
- const rowsFor = (extraActivities: ReadonlyArray>) =>
- buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-spawn"),
- projectId: ProjectId.make("project-1"),
- title: "Spawns",
- activities: [...activities, ...extraActivities],
- }),
- ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
-
- // The batch anchors on the first task.started: a fixed id and timestamp,
- // unlike progress ticks (which the server rewrites in place).
- const running = rowsFor([]);
- expect(running.map((row) => [row.id, row.summary])).toEqual([
- ["a-start", "Kicked off 2 subagents · 2 working"],
- ["shell-1", "Run tests"],
- ]);
- expect(running[0]).toMatchObject({
- createdAt: "2026-04-01T00:00:01.000Z",
- lifecycleStatus: "inProgress",
- workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } },
- });
-
- const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]);
- expect(oneDone[0]).toMatchObject({
- id: "a-start",
- summary: "Kicked off 2 subagents · 1 working",
- lifecycleStatus: "inProgress",
- });
+ it.each([
+ { envelope: "direct", output: { taskId: "a" } },
+ { envelope: "structured", output: { structuredContent: { taskId: "a" } } },
+ { envelope: "text", output: { content: [{ type: "text", text: '{"taskId":"a"}' }] } },
+ ])(
+ "folds matched $envelope delegations without hiding pending, failed or unmatched calls",
+ ({ output }) => {
+ const agent = (
+ id: string,
+ index: number,
+ origin = "app_owned" as "app_owned" | "provider_native",
+ ) =>
+ projected(
+ {
+ ...base(id, "2026-06-20T00:00:01.000Z", index),
+ type: "subagent",
+ subagentId: NodeId.make(id),
+ origin,
+ driver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ childThreadId: ThreadId.make(`child-${id}`),
+ prompt: "Identical task",
+ result: "Done",
+ },
+ index,
+ );
+ const delegation = (
+ id: string,
+ index: number,
+ overrides: Partial> = {},
+ ) =>
+ projected(
+ {
+ ...base(id, "2026-06-20T00:00:02.000Z", index),
+ type: "dynamic_tool",
+ toolName: "t3-code.delegate_task",
+ input: { task: "Identical task" },
+ output,
+ ...overrides,
+ },
+ index,
+ );
+ const feed = buildThreadFeed([
+ agent("a", 1),
+ delegation("matched", 2),
+ agent("b", 3),
+ delegation("pending", 4, { status: "running", output: null }),
+ delegation("unmatched", 5, { output: { taskId: "missing" } }),
+ delegation("failed", 6, { status: "failed" }),
+ delegation("error-output", 7, { output: { taskId: "a", isError: true } }),
+ delegation("other-run", 8, { runId: RunId.make("other-run") }),
+ agent("native", 9, "provider_native"),
+ delegation("native-delegation", 10, { output: { taskId: "native" } }),
+ ]);
+ const groups = feed.flatMap((entry) =>
+ entry.type === "activity-group"
+ ? [entry.activities.map((activity) => activity.projectedItem.item.id)]
+ : [],
+ );
+ expect(groups[0]).toEqual(["a", "b"]);
+ expect(groups.flat()).toEqual([
+ "a",
+ "b",
+ "pending",
+ "unmatched",
+ "failed",
+ "error-output",
+ "other-run",
+ "native",
+ "native-delegation",
+ ]);
+ const presented = deriveThreadFeedPresentation(
+ feed,
+ null,
+ new Set([runId, RunId.make("other-run")]),
+ );
+ expect(
+ presented.find(
+ (entry) =>
+ entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.id === "a",
+ )?.continuesWorkLog,
+ ).toBeUndefined();
+ },
+ );
- const allDone = rowsFor([
- agent("a-done", "task.completed", "a", "completed", 7),
- agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }),
- ]);
- expect(allDone[0]).toMatchObject({
- id: "a-start",
- summary: "Ran 2 subagents · 1 failed",
- lifecycleStatus: "failed",
- status: "failure",
- });
- expect(allDone).toHaveLength(2);
+ it("keeps subagents from different provider turns in separate cards", () => {
+ const agent = (id: string, index: number) =>
+ projected(
+ {
+ ...base(id, "2026-06-20T00:00:01.000Z", index),
+ type: "subagent",
+ subagentId: NodeId.make(id),
+ origin: "provider_native",
+ driver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ providerTurnId: ProviderTurnId.make(id),
+ childThreadId: null,
+ prompt: "Task",
+ result: null,
+ },
+ index,
+ );
+ expect(
+ buildThreadFeed([agent("a", 1), agent("b", 2)]).flatMap((entry) =>
+ entry.type === "activity-group"
+ ? [entry.activities.map((activity) => activity.projectedItem.item.id)]
+ : [],
+ ),
+ ).toEqual([["a"], ["b"]]);
});
- it("folds the tool call that launched an agent into its spawn card", () => {
- const turnId = TurnId.make("turn-agent-tool");
- const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
- const feed = buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-agent-tool"),
- projectId: ProjectId.make("project-1"),
- title: "Agent tool",
- activities: [
- makeActivity({
- id: EventId.make("agent-call-updated"),
- kind: "tool.updated",
- tone: "tool",
- summary: "Subagent task",
- createdAt: at(1),
- turnId,
- payload: {
- itemType: "collab_agent_tool_call",
- toolCallId: "toolu_agent",
- status: "inProgress",
- title: "Subagent task",
- detail: "Locate code",
- data: { toolName: "Agent" },
- },
- }),
- makeActivity({
- id: EventId.make("agent-started"),
- kind: "task.started",
- summary: "Locate code",
- createdAt: at(2),
- turnId,
- payload: {
- taskId: "a1",
- agentKind: "agent",
- taskType: "local_agent",
- title: "Locate code",
- toolUseId: "toolu_agent",
- },
- }),
- makeActivity({
- id: EventId.make("agent-done"),
- kind: "task.completed",
- summary: "Locate code",
- createdAt: at(3),
- turnId,
- payload: {
- taskId: "a1",
- agentKind: "agent",
- taskType: "local_agent",
- title: "Locate code",
- toolUseId: "toolu_agent",
- status: "completed",
- },
- }),
- makeActivity({
- id: EventId.make("agent-call-completed"),
- kind: "tool.completed",
- tone: "tool",
- summary: "Subagent task",
- createdAt: at(4),
- turnId,
- payload: {
- itemType: "collab_agent_tool_call",
- toolCallId: "toolu_agent",
- status: "completed",
- title: "Subagent task",
- detail: "Locate code",
- data: { toolName: "Agent" },
- },
- }),
- ],
- }),
+ it("groups only adjacent subagents in the same run, keeping their child links", () => {
+ const agent = (id: string, index: number, agentRunId = runId) =>
+ projected(
+ {
+ ...base(id, `2026-06-20T00:00:0${index}.000Z`, index),
+ type: "subagent",
+ runId: agentRunId,
+ subagentId: NodeId.make(id),
+ origin: "app_owned",
+ driver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ childThreadId: ThreadId.make(`child-${id}`),
+ prompt: "Solve the puzzle",
+ result: "Done",
+ },
+ index,
+ );
+ const feed = buildThreadFeed([
+ agent("a", 1),
+ agent("b", 2),
+ projected(command("2026-06-20T00:00:03.000Z"), 3),
+ agent("c", 4),
+ agent("d", 5, RunId.make("other-run")),
+ ]);
+ expect(
+ feed.flatMap((entry) =>
+ entry.type === "activity-group"
+ ? [entry.activities.map((activity) => activity.projectedItem.item.id)]
+ : [],
+ ),
+ ).toEqual([["a", "b"], ["item-command"], ["c"], ["d"]]);
+ const presented = deriveThreadFeedPresentation(
+ feed,
+ null,
+ new Set([runId, RunId.make("other-run")]),
);
- const rows = feed.flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
+ const groups = presented.flatMap((entry) =>
+ entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.type === "subagent"
+ ? [entry]
+ : [],
);
- expect(rows).toEqual(["agent-started"]);
- expect(
- deriveThreadFeedPresentation(feed, null, new Set([turnId])).map((row) => row.type),
- ).toEqual(["turn-fold", "agent-spawn"]);
+ expect(groups.map((entry) => entry.activities.length)).toEqual([2, 1, 1]);
+ expect(groups[0]?.activities.map((activity) => activity.projectedItem.item)).toMatchObject([
+ { childThreadId: "child-a" },
+ { childThreadId: "child-b" },
+ ]);
});
- it("presents a spawn batch as one card whose status line follows the newest member activity", () => {
- const turnId = TurnId.make("turn-spawn-card");
- const latestTurn = {
- turnId,
- state: "running" as const,
- requestedAt: "2026-04-01T00:00:00.000Z",
- startedAt: "2026-04-01T00:00:00.000Z",
- completedAt: null,
- assistantMessageId: null,
- };
- const agent = (
- id: string,
- kind: "task.started" | "task.progress" | "task.completed",
- taskId: string,
- seconds: number,
- extra: Record = {},
- ) =>
- makeActivity({
- id: EventId.make(id),
- kind,
- summary: `Agent ${taskId}`,
- createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
- turnId,
- payload: {
- taskId,
- agentKind: "agent",
- taskType: "local_agent",
- title: `Agent ${taskId}`,
- ...extra,
+ it("shows an idle native subagent without claiming completion", () => {
+ const rows = buildThreadFeed([
+ projected(
+ {
+ ...base("native-agent", "2026-06-20T00:00:02.000Z", 1),
+ type: "subagent",
+ status: "idle",
+ subagentId: NodeId.make("native-agent"),
+ origin: "provider_native",
+ driver: ProviderDriverKind.make("antigravity"),
+ providerInstanceId: ProviderInstanceId.make("antigravity"),
+ childThreadId: null,
+ title: "Search",
+ prompt: "Find relevant files",
+ result: null,
},
- });
- const presentFor = (activities: ReadonlyArray>) =>
- deriveThreadFeedPresentation(
- buildThreadFeed(
- makeThread({
- id: ThreadId.make("thread-spawn-card"),
- projectId: ProjectId.make("project-1"),
- title: "Spawn card",
- latestTurn,
- activities,
- }),
- ),
- latestTurn,
- new Set(),
- new Set(),
- latestTurn.startedAt,
- );
-
- // A working card is the live activity; no Thinking row sits under it.
- const single = presentFor([agent("a-start", "task.started", "a", 1)]);
- expect(single.map((row) => row.type)).toEqual(["agent-spawn"]);
- expect(single[0]).toMatchObject({
- id: `agent-spawn:${turnId}`,
- summary: { title: "Agent a", status: "Working", tone: "working" },
- });
-
- // The server upserts the progress row with a new createdAt each tick;
- // the card keeps its identity and only the status line changes.
- const tick = (seconds: number, detail: string) =>
- presentFor([
- agent("a-start", "task.started", "a", 1),
- agent("task-progress:a", "task.progress", "a", seconds, { detail }),
- ]);
- expect(tick(2, "Reading a.ts")[0]).toMatchObject({
- id: `agent-spawn:${turnId}`,
- createdAt: "2026-04-01T00:00:01.000Z",
- summary: { title: "Agent a", status: "Reading a.ts", tone: "working" },
- });
- expect(tick(3, "Reading b.ts")[0]).toMatchObject({
- id: `agent-spawn:${turnId}`,
- createdAt: "2026-04-01T00:00:01.000Z",
- summary: { status: "Reading b.ts" },
- });
-
- const batch = presentFor([
- agent("a-start", "task.started", "a", 1),
- agent("b-start", "task.started", "b", 2),
- agent("task-progress:b", "task.progress", "b", 3, { detail: "Grepping" }),
- agent("a-done", "task.completed", "a", 4, { status: "completed" }),
+ 0,
+ ),
]);
- expect(batch[0]).toMatchObject({
- id: `agent-spawn:${turnId}`,
- summary: {
- title: "2 subagents",
- status: "Grepping",
- tone: "working",
- members: [
- { title: "Agent a", status: "completed", tone: "completed" },
- { title: "Agent b", status: "working", tone: "working", detail: "Grepping" },
- ],
- },
+ expect(rows[0]).toMatchObject({
+ type: "activity-group",
+ activities: [{ status: "neutral", lifecycleStatus: "idle", prominent: false }],
});
-
- const settled = presentFor([
- agent("a-start", "task.started", "a", 1),
- agent("b-start", "task.started", "b", 2),
- agent("a-done", "task.completed", "a", 4, { status: "completed" }),
- agent("b-done", "task.completed", "b", 5, { status: "failed", error: "boom" }),
+ expect(deriveThreadFeedPresentation(rows, null, new Set([runId]))).toMatchObject([
+ { type: "run-fold", expanded: true },
+ { type: "activity-group", activities: [{ lifecycleStatus: "idle" }] },
]);
- expect(settled[0]).toMatchObject({
- type: "agent-spawn",
- summary: { title: "2 subagents", status: "1 failed", tone: "failed" },
- });
- expect(settled.map((row) => row.type)).toEqual(["agent-spawn", "thinking"]);
});
+});
- it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
- "replaces Antigravity batch progress with %s",
- (status) => {
- const detail =
- status === "idle"
- ? "Turn ended. Individual agent status is unavailable."
- : "Antigravity process stopped.";
- const thread = makeThread({
- id: ThreadId.make("antigravity-agents"),
- projectId: ProjectId.make("project-1"),
- title: "Antigravity subagents",
- activities: [
- ...["trajectory:4", "trajectory:5"].map((taskId, index) =>
- makeActivity({
- id: EventId.make(`progress-${index}`),
- kind: "task.progress",
- summary: "Antigravity subagent batch",
- createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,
- payload: {
- taskId,
- taskType: "subagent_batch",
- agentKind: "agent",
- title: "Antigravity subagent batch",
- detail: "Antigravity subagent batch",
- status: "running",
- },
- }),
- ),
- makeActivity({
- id: EventId.make("agent-stopped"),
- kind: "task.updated",
- summary: `Task ${status}`,
- createdAt: "2026-04-01T00:00:03.000Z",
- payload: {
- taskId: "trajectory:4",
- taskType: "subagent_batch",
- agentKind: "agent",
- title: "Antigravity subagent batch",
- status,
- ...(status === "idle" ? { detail, timelineBypass: true } : { error: detail }),
- },
- }),
- ],
- });
- const rows = buildThreadFeed(thread).flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities : [],
- );
- // Turn-less batches never share a spawn group, so each keeps its own row.
- expect(rows).toHaveLength(2);
- expect(rows[0]).toMatchObject({
- lifecycleStatus: status === "failed" ? "failed" : "stopped",
- summary: `Ran 1 subagent · ${status === "failed" ? "1 failed" : "1 stopped"}`,
- workEntry: {
- taskId: "trajectory:4",
- toolTitle: "Antigravity subagent batch",
- agentSpawn: { agents: [{ detail }] },
- },
- });
- expect(rows[0]?.getFullDetail()).toContain(detail);
- expect(rows[1]).toMatchObject({
- lifecycleStatus: "inProgress",
- summary: "Kicked off 1 subagent · 1 working",
- workEntry: { taskId: "trajectory:5" },
- });
- },
- );
+const singleSelectQuestion = {
+ id: "runtime",
+ header: "Runtime",
+ question: "Which runtime should be used?",
+ options: [
+ { label: "Go", description: "One binary" },
+ { label: "Node.js", description: "Reuse TypeScript" },
+ ],
+ multiSelect: false,
+} as const;
- it("folds bypassed Claude workflow members into the coordinator's batch and settles them with it", () => {
- const turnId = TurnId.make("turn-workflow");
- const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
- const thread = makeThread({
- id: ThreadId.make("thread-workflow"),
- projectId: ProjectId.make("project-1"),
- title: "Workflow",
- activities: [
- makeActivity({
- id: EventId.make("wf-progress"),
- kind: "task.progress",
- summary: "Workflow running",
- createdAt: at(1),
- turnId,
- payload: {
- taskId: "wf-1",
- taskType: "local_workflow",
- workflowName: "review",
- agentKind: "agent",
- title: "review",
- status: "running",
- },
- }),
- // Members are synthesized with timelineBypass and never render alone.
- ...[0, 1].map((index) =>
- makeActivity({
- id: EventId.make(`member-${index}`),
- kind: "task.progress",
- summary: `Agent ${index}`,
- createdAt: at(2 + index),
- turnId,
- payload: {
- taskId: `wf-1:wf:${index}`,
- agentKind: "agent",
- title: `Reviewer ${index}`,
- description: `Reviewer ${index}`,
- status: index === 0 ? "completed" : "running",
- parentAgentId: "wf-1",
- timelineBypass: true,
- },
- }),
- ),
- makeActivity({
- id: EventId.make("wf-done"),
- kind: "task.completed",
- summary: "Task completed",
- createdAt: at(10),
- turnId,
- payload: {
- taskId: "wf-1",
- taskType: "local_workflow",
- workflowName: "review",
- agentKind: "agent",
- status: "completed",
- title: "review",
- },
- }),
- ],
- });
- const rows = buildThreadFeed(thread).flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities : [],
+const multiSelectQuestion = {
+ id: "scope",
+ header: "Scope",
+ question: "Which data should be collected?",
+ options: [
+ { label: "Orders", description: "Receipts" },
+ { label: "Listings", description: "Inventory" },
+ ],
+ multiSelect: true,
+} as const;
+
+describe("pending user input answers", () => {
+ it("replaces single-select options and toggles multi-select options", () => {
+ expect(
+ togglePendingUserInputOptionSelection(
+ singleSelectQuestion,
+ { selectedOptionValues: ["Go"] },
+ "Node.js",
+ ),
+ ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] });
+
+ const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
+ const ordersAndListings = togglePendingUserInputOptionSelection(
+ multiSelectQuestion,
+ orders,
+ "Listings",
);
- expect(rows).toHaveLength(1);
- // The member that never reported its own end settles with the coordinator.
- expect(rows[0]).toMatchObject({
- id: "wf-progress",
- summary: "Ran 2 subagents · completed",
- lifecycleStatus: "completed",
- workEntry: {
- agentSpawn: {
- workflowId: "wf-1",
- agentTaskIds: ["wf-1", "wf-1:wf:0", "wf-1:wf:1"],
- },
- },
+ expect(ordersAndListings).toEqual({
+ customAnswer: "",
+ selectedOptionValues: ["Orders", "Listings"],
});
- expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");
+ expect(
+ togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
+ ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
+
+ const paddedOrders = togglePendingUserInputOptionSelection(
+ multiSelectQuestion,
+ undefined,
+ " Orders ",
+ );
+ expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
+ expect(
+ togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
+ ).toEqual({ customAnswer: "" });
});
- it("summarizes a spawn card from the newest member report and the batch outcome", () => {
- type Member = NonNullable["agents"][number];
- const member = (title: string, status: Member["status"], detail: string, seconds: number) =>
- ({
- title,
- status,
- detail,
- updatedAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
- }) satisfies Member;
- const direct = (agents: ReadonlyArray) => ({
- workflowId: null,
- agentTaskIds: agents.map((_, index) => `a${index}`),
- agents,
+ it("builds array answers for multi-select questions", () => {
+ expect(
+ buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
+ runtime: { selectedOptionValues: ["Go"] },
+ scope: { selectedOptionValues: ["Orders", "Listings"] },
+ }),
+ ).toEqual({
+ runtime: "Go",
+ scope: ["Orders", "Listings"],
});
+ });
- // The newest report wins regardless of member order.
+ it("clears selected options while a custom answer is active", () => {
expect(
- agentSpawnSummary(
- direct([
- member("Agent 0", "inProgress", "Reading b.ts", 5),
- member("Agent 1", "inProgress", "Reading a.ts", 2),
- ]),
- "inProgress",
+ setPendingUserInputCustomAnswer(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders", "Listings"] },
+ "Orders first",
),
- ).toMatchObject({ title: "2 subagents", status: "Reading b.ts", tone: "working" });
+ ).toEqual({ customAnswer: "Orders first" });
+ });
- // A declined request is a failed batch, not a completed one.
- expect(
- agentSpawnSummary(direct([member("Agent 0", "declined", "", 1)]), "declined"),
- ).toMatchObject({ status: "failed", tone: "failed" });
-
- // A coordinator that failed on its own reports the failure even when every
- // member succeeded; before any member reports, the card has a neutral title.
- const workflow = (agents: ReadonlyArray) => ({
- workflowId: "wf",
- agentTaskIds: ["wf", ...agents.map((_, index) => `wf:wf:${index}`)],
- agents: [member("review", "failed", "", 9), ...agents],
- });
+ it("matches selected chips against normalized option labels", () => {
expect(
- agentSpawnSummary(workflow([member("Reviewer", "completed", "", 3)]), "failed"),
- ).toMatchObject({ title: "Reviewer", status: "failed", tone: "failed" });
+ isPendingUserInputOptionSelected(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders"] },
+ " Orders ",
+ ),
+ ).toBe(true);
expect(
- agentSpawnSummary(
- { workflowId: "wf", agentTaskIds: ["wf"], agents: [member("review", undefined, "", 1)] },
- "inProgress",
+ isPendingUserInputOptionSelected(
+ multiSelectQuestion,
+ { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
+ " Orders ",
),
- ).toMatchObject({ title: "Subagents", status: "Working", tone: "working", members: [] });
+ ).toBe(false);
});
+});
- it("treats a Codex child's idle turn end as a finished batch member", () => {
- const turnId = TurnId.make("turn-codex");
- const child = (
- id: string,
- kind: "task.started" | "task.updated",
- status: string,
- seconds: number,
- ) =>
- makeActivity({
- id: EventId.make(id),
- kind,
- summary: `${status}`,
- createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
- turnId,
- payload: {
- taskId: "child-1",
- agentKind: "agent",
- title: "math_one",
- status,
- timelineBypass: true,
- },
- });
- const thread = makeThread({
- id: ThreadId.make("thread-codex"),
- projectId: ProjectId.make("project-1"),
- title: "Codex children",
- activities: [
- child("c-start", "task.started", "running", 1),
- child("c-running", "task.updated", "running", 2),
- child("c-idle", "task.updated", "idle", 5),
- ],
- });
- const rows = buildThreadFeed(thread).flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities : [],
- );
- expect(rows).toHaveLength(1);
- expect(rows[0]).toMatchObject({
- summary: "Ran 1 subagent · completed",
- lifecycleStatus: "completed",
- });
- });
+describe("provider question values", () => {
+ const question = {
+ ...singleSelectQuestion,
+ allowCustomAnswer: false,
+ options: [
+ { label: "Same label", value: " exact first ", description: "First" },
+ { label: "Same label", value: "second", description: "Second" },
+ ],
+ } as const;
- it("keeps a nested agent's terminal row but hides its background work", () => {
- const thread = makeThread({
- id: ThreadId.make("thread-nested"),
- projectId: ProjectId.make("project-1"),
- title: "Nested agents",
- activities: [
- // A subagent's own shell: internal, covered by the owner's liveness.
- makeActivity({
- id: EventId.make("shell-done"),
- kind: "task.completed",
- summary: "Task completed",
- createdAt: "2026-04-01T00:00:02.000Z",
- payload: { taskId: "sh-1", agentId: "owner", agentKind: "background" },
- }),
- // A nested AGENT's completion: mobile has no Agents sheet, so this
- // terminal row is the only signal it ever finished.
- makeActivity({
- id: EventId.make("nested-done"),
- kind: "task.completed",
- summary: "Task completed",
- createdAt: "2026-04-01T00:00:03.000Z",
- payload: { taskId: "n-1", agentId: "owner", agentKind: "agent" },
- }),
- ],
+ it("submits raw option values and distinguishes duplicate labels", () => {
+ const first = togglePendingUserInputOptionSelection(question, undefined, " exact first ");
+ expect(isPendingUserInputOptionSelected(question, first, " exact first ")).toBe(true);
+ expect(isPendingUserInputOptionSelected(question, first, "second")).toBe(false);
+ expect(buildPendingUserInputAnswers([question], { runtime: first })).toEqual({
+ runtime: " exact first ",
});
+ expect(togglePendingUserInputOptionSelection(question, first, "Same label")).toBe(first);
+ });
- const feed = buildThreadFeed(thread);
- const ids = feed.flatMap((entry) =>
- entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
- );
- expect(ids).toContain("nested-done");
- expect(ids).not.toContain("shell-done");
- expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([
- {
- type: "agent-spawn",
- id: "agent-spawn:n-1",
- activity: { id: "nested-done" },
- summary: { title: "Task completed", status: "completed", tone: "completed" },
- },
- ]);
+ it("rejects arbitrary text when the provider only accepts offered options", () => {
+ expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({});
+ expect(
+ buildPendingUserInputAnswers([question], { runtime: { customAnswer: "Other" } }),
+ ).toBeNull();
+ expect(
+ buildPendingUserInputAnswers([question], { runtime: { selectedOptionValues: ["unknown"] } }),
+ ).toBeNull();
+ expect(
+ buildPendingUserInputAnswers([question], {
+ runtime: { selectedOptionValues: ["second"], customAnswer: "stale draft" },
+ }),
+ ).toEqual({ runtime: "second" });
});
});
@@ -3657,10 +1996,9 @@ it("accepts ready attachment-only answers while preserving selected options", ()
).toBeNull();
});
-it("keeps attachment-only question answers expandable outside mobile work groups and turn folds", () => {
- const turnId = TurnId.make("turn-answer");
+it("makes attachment-only question answers expandable in the mobile feed", () => {
const answer = {
- requestId: ApprovalRequestId.make("question-request"),
+ requestId: RuntimeRequestId.make("question-request"),
answers: { q: "" },
questionTextById: { q: "Attach the specification" },
attachmentsByQuestionId: {
@@ -3675,76 +2013,280 @@ it("keeps attachment-only question answers expandable outside mobile work groups
],
},
};
- const thread = makeThread({
- id: ThreadId.make("thread-answer"),
- projectId: ProjectId.make("project-answer"),
- title: "Answer history",
- latestTurn: {
- turnId,
- state: "completed",
- requestedAt: "2026-09-08T00:00:00.000Z",
- startedAt: "2026-09-08T00:00:00.000Z",
- completedAt: "2026-09-08T00:00:04.000Z",
- assistantMessageId: null,
- },
- activities: [
- makeActivity({
- id: EventId.make("tool-before-answer"),
- createdAt: "2026-09-08T00:00:01.000Z",
- kind: "tool.completed",
- tone: "tool",
- summary: "Read files",
- turnId,
- payload: { itemType: "command_execution", status: "completed" },
- }),
- makeActivity({
- id: EventId.make("answer-submitted"),
- createdAt: "2026-09-08T00:00:02.000Z",
- kind: "user-input.answer-submitted",
- summary: "Answered questions",
- turnId,
- payload: answer,
- }),
- makeActivity({
- id: EventId.make("tool-after-answer"),
- createdAt: "2026-09-08T00:00:03.000Z",
- kind: "tool.completed",
- tone: "tool",
- summary: "Read files",
- turnId,
- payload: { itemType: "command_execution", status: "completed" },
- }),
- ],
- });
- const feed = buildThreadFeed(thread);
- expect(feed).toHaveLength(3);
- const group = feed[1];
+ const [group] = buildThreadFeed([
+ projected(
+ {
+ ...base("answer-history", "2026-09-08T00:00:00.000Z", 0),
+ type: "user_input_request",
+ requestId: answer.requestId,
+ questions: [],
+ questionAnswer: answer,
+ },
+ 0,
+ ),
+ ]);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
expect(group.activities[0]).toMatchObject({
canExpand: true,
workEntry: { questionAnswer: answer },
});
- expect(group.activities[0]?.getFullDetail()).toBeNull();
- const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
- expect(collapsed.map((entry) => entry.type)).toEqual(["turn-fold", "activity-group"]);
- expect(collapsed[1]).toBe(group);
- const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
- expect(expanded.map((entry) => entry.type)).toEqual([
- "turn-fold",
- "work-toggle",
- "activity-group",
- "work-toggle",
+ expect(group.activities[0]?.getFullDetail()).toContain("spec.txt");
+});
+
+it("renders automatic completion as a neutral activity while retaining its details", () => {
+ const item = {
+ ...base("notification", "2026-06-20T00:00:01.000Z", 0),
+ type: "notification" as const,
+ source: { kind: "monitor" as const },
+ outcome: "updated" as const,
+ summary: "Monitor reported an update",
+ detail: "Build checks changed",
+ };
+ const feed = buildThreadFeed([
+ projected(item, 0),
+ projected(command(), 1),
+ projected(assistantMessage(), 2),
]);
- expect(expanded[2]).toBe(group);
- const running = deriveThreadFeedPresentation(
+ expect(feed[0]?.type).toBe("activity-group");
+ if (feed[0]?.type !== "activity-group") throw new Error("Expected notification activity");
+ const activity = feed[0].activities[0]!;
+ expect(activity.summary).toBe("Monitor reported an update");
+ expect(activity.detail).toBeNull();
+ expect(activity.status).toBeNull();
+ expect(activity.getFullDetail()).toContain(item.detail);
+ const presented = deriveThreadFeedPresentation(
feed,
- { ...thread.latestTurn!, state: "running", completedAt: null },
- new Set(),
+ {
+ runId,
+ status: "completed",
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
+ },
new Set(),
- "2026-09-08T00:00:00.000Z",
);
- expect(running[0]?.type).toBe("work-toggle");
- expect(running[1]).toBe(group);
- expect(running[2]?.type).toBe("work-toggle");
+ expect(
+ presented.some(
+ (entry) =>
+ entry.type === "activity-group" &&
+ entry.activities.some((activity) => activity.summary === "Monitor reported an update"),
+ ),
+ ).toBe(true);
+ expect(buildThreadFeed([projected(userMessage(), 0)])[0]?.type).toBe("message");
+});
+
+it("uses a compact reasoning preview and a short expanded heading", () => {
+ const entry = {
+ id: "thought",
+ label: "Thinking",
+ createdAt: "2026-09-17T12:00:00Z",
+ itemType: "reasoning" as const,
+ tone: "thinking" as const,
+ detail: "Check **ordering**.\nThen run the test.",
+ toolLifecycleStatus: "inProgress" as const,
+ };
+ expect(workEntryRowLabel(entry)).toBe("Check **ordering**. Then run the test.");
+ expect(workEntryRowLabel(entry, true)).toBe("Thinking");
+ expect(workEntryRowLabel({ ...entry, toolLifecycleStatus: "completed" }, true)).toBe("Thought");
+});
+
+it("keeps search output in expanded details rather than the compact label", () => {
+ const entry = {
+ id: "search",
+ label: "Grep",
+ toolTitle: "Grep",
+ createdAt: "2026-09-17T12:00:00Z",
+ itemType: "dynamic_tool" as const,
+ tone: "tool" as const,
+ detail: "---\nfile body",
+ toolData: {},
+ };
+ expect(workEntryRowLabel(entry)).toBe("Grep");
+ expect(workEntryRowLabel(entry, true)).toBe("---\nfile body");
+});
+
+it.each(["First paragraph.\n\nSecond paragraph.", ""])(
+ "previews live reasoning text %j",
+ (text) => {
+ const thought: OrchestrationV2TurnItem = {
+ ...base("live-thought", "2026-06-20T00:00:02.000Z", 1),
+ type: "reasoning",
+ status: "running",
+ completedAt: null,
+ streaming: true,
+ text,
+ };
+ const feed = buildThreadFeed([projected(userMessage(), 0), projected(thought, 1)]);
+ const rows = deriveThreadFeedPresentation(
+ feed,
+ { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null },
+ new Set(),
+ new Set(),
+ "2026-06-20T00:00:01.000Z",
+ );
+ if (text) {
+ expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({
+ summary: "First paragraph. Second paragraph.",
+ live: true,
+ });
+ } else {
+ expect(
+ rows.some(
+ (row) =>
+ row.type === "thinking" || (row.type === "work-toggle" && row.summary === "Thinking"),
+ ),
+ ).toBe(true);
+ }
+ },
+);
+
+it("stops stranded thinking after a steer and follows the next thought or tool", () => {
+ const at = "2026-06-20T00:00:02.000Z";
+ const thought = (id: string): OrchestrationV2TurnItem => ({
+ ...base(id, at, 1),
+ type: "reasoning",
+ status: "running",
+ completedAt: null,
+ streaming: true,
+ text: id,
+ });
+ const first = thought("first-thought");
+ const next = thought("next-thought");
+ const steer = { ...userMessage(at), inputIntent: "steer" as const };
+ const tool = { ...command(at), status: "running" as const, completedAt: null };
+ const rows = (items: ReadonlyArray, expanded = new Set()) =>
+ deriveThreadFeedPresentation(
+ buildThreadFeed(items.map((item, position) => projected(item, position))),
+ { runId, status: "running", startedAt: at, completedAt: null },
+ new Set(),
+ expanded,
+ at,
+ );
+ expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({
+ summary: "first-thought",
+ live: true,
+ shimmer: true,
+ });
+ const afterSteer = rows([first, steer]);
+ expect(afterSteer.find((row) => row.type === "work-toggle")).toMatchObject({
+ live: false,
+ shimmer: false,
+ });
+ expect(afterSteer.at(-1)?.type).toBe("thinking");
+ const header = afterSteer.find((row) => row.type === "work-toggle");
+ if (header?.type !== "work-toggle") throw new Error("Expected thought toggle");
+ const expanded = rows([first, steer], new Set([header.groupId]));
+ expect(expanded.find((row) => row.type === "work-toggle")).toMatchObject({ summary: "Thought" });
+ expect(expanded.find((row) => row.type === "activity-group")).toMatchObject({
+ activities: [{ lifecycleStatus: "completed", workEntry: { toolLifecycleStatus: "completed" } }],
+ });
+ for (const items of [
+ [first, steer, next],
+ [first, next],
+ [first, steer, next, tool],
+ ]) {
+ const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer);
+ expect(live).toHaveLength(1);
+ expect(live[0]).toMatchObject({
+ summary: items.at(-1)!.type === "reasoning" ? "next-thought" : "Running vp",
+ });
+ }
+ expect(first.status).toBe("running");
+});
+
+it("previews a settled thought in its collapsed header and labels its expanded header", () => {
+ const thought: OrchestrationV2TurnItem = {
+ ...base("thought-preview", "2026-06-20T00:00:02.000Z", 1),
+ type: "reasoning",
+ streaming: false,
+ text: "First paragraph.\n\nSecond paragraph.",
+ };
+ const feed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(thought, 1),
+ projected(assistantMessage(), 2),
+ ]);
+ const run = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:03.000Z",
+ };
+ const collapsed = deriveThreadFeedPresentation(feed, run, new Set([runId]));
+ const header = collapsed.find((row) => row.type === "work-toggle");
+ expect(header).toMatchObject({ summary: "First paragraph. Second paragraph." });
+ if (header?.type !== "work-toggle") throw new Error("Expected thought toggle");
+ const expanded = deriveThreadFeedPresentation(
+ feed,
+ run,
+ new Set([runId]),
+ new Set([header.groupId]),
+ );
+ expect(expanded.find((row) => row.type === "work-toggle")).toMatchObject({
+ summary: "Thought",
+ continuesWorkLog: true,
+ });
+ const detail = expanded.find((row) => row.type === "activity-group");
+ expect(detail?.continuesWorkLog).toBeUndefined();
+ if (detail?.type !== "activity-group") throw new Error("Expected full thought");
+ expect(detail.activities[0]?.detail).toBe(thought.text);
});
+
+it.each(["provider_error", "usage_limit"] as const)(
+ "keeps a historical %s failure and preceding work visible without disclosures",
+ (failureClass) => {
+ const at = "2026-06-20T00:00:03.000Z";
+ const error: OrchestrationV2TurnItem = {
+ ...base("failure", at, 2),
+ type: "error",
+ status: "failed",
+ failure: {
+ class: failureClass,
+ message: "The provider stopped this turn.\nRetry later.",
+ code: null,
+ retryable: true,
+ },
+ };
+ const command: OrchestrationV2TurnItem = {
+ ...base("command", "2026-06-20T00:00:02.000Z", 1),
+ type: "command_execution",
+ input: "pwd",
+ output: "",
+ exitCode: 0,
+ };
+ const sourceFeed = buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(command, 1),
+ projected(error, 2),
+ ]);
+ const feed = deriveThreadFeedPresentation(
+ sourceFeed,
+ { runId: RunId.make("newer-run"), status: "completed", startedAt: at, completedAt: at },
+ new Set(),
+ );
+ const whileWorking = deriveThreadFeedPresentation(
+ sourceFeed,
+ { runId: RunId.make("newer-run"), status: "running", startedAt: at, completedAt: null },
+ new Set(),
+ );
+ for (const entry of feed) {
+ expect(whileWorking.find((row) => row.id === entry.id)).toBe(entry);
+ }
+ expect(feed.some((entry) => entry.type === "run-fold" || entry.type === "work-toggle")).toBe(
+ false,
+ );
+ const activities = feed.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities : [],
+ );
+ expect(activities.map((activity) => activity.projectedItem.item.id)).toEqual([
+ "command",
+ "failure",
+ ]);
+ expect(activities.at(-1)).toMatchObject({
+ detail: error.failure.message,
+ createdAt: at,
+ canExpand: false,
+ prominent: true,
+ });
+ },
+);
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 8f000b1b55fc..c453ebf2312d 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -1,45 +1,63 @@
-import * as Option from "effect/Option";
-import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";
-import * as Schema from "effect/Schema";
-import {
- requestKindFromRequestType,
- type PendingApproval,
-} from "@t3tools/client-runtime/pending-requests";
-import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts";
import type {
- OrchestrationLatestTurn,
- OrchestrationThread,
- OrchestrationThreadActivity,
- ToolLifecycleItemType,
- TurnId,
- UserInputQuestion,
-} from "@t3tools/contracts";
-import { formatDuration } from "@t3tools/shared/orchestrationTiming";
+ ThreadPendingApproval,
+ ThreadPendingUserInput,
+ ThreadUserInputQuestion,
+} from "@t3tools/client-runtime/state/thread-requests";
+import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
+import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
+import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
import {
- commandDetailRepeatsCommand,
- extractCommandOutputText,
- extractWorkLogToolLifecycleStatus,
- isWorktreeSetupActivity,
+ commandDisplayText,
+ commandProgramName,
+} from "@t3tools/client-runtime/work-log/command-label";
+import {
+ contextCompactionLabel,
+ toolItemForDisplay,
+ workEntryDisplayIndicatesToolFailure,
liveActivityToolStatus,
- normalizeCompactToolLabel,
- omitSupersededLifecycleMarkers,
+ toolGroupAction,
resolveWorkEntryToolPresentation,
summarizeToolGroup,
- toolGroupAction,
toolGroupSummaryKind,
- workEntryIndicatesToolFailure,
- workEntryIndicatesToolSuccess,
- workLogEntryIsToolLike,
type ToolGroupSummaryKind,
+ type WorkLogPresentationEntry,
type WorkLogToolLifecycleStatus,
} from "@t3tools/client-runtime/work-log/presentation";
-import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
-import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
-
-import * as Arr from "effect/Array";
-import * as Order from "effect/Order";
+import {
+ resolveT3McpToolDefinition,
+ resolveT3McpToolPresentation,
+ type T3McpToolLogo,
+ type T3McpToolPresentation,
+} from "@t3tools/shared/t3McpToolPresentation";
+import type {
+ ChatAttachment,
+ MessageId,
+ OrchestrationV2Actor,
+ OrchestrationV2CreationSource,
+ OrchestrationV2ExecutionNode,
+ OrchestrationMessageContext,
+ OrchestrationV2ProjectedTurnItem,
+ OrchestrationV2RunAttempt,
+ OrchestrationV2RunStatus,
+ OrchestrationV2TurnItem,
+ OrchestrationV2UserMessageInputIntent,
+ RunAttemptId,
+ ScheduledTaskId,
+} from "@t3tools/contracts";
+import { RunId, ThreadId } from "@t3tools/contracts";
+import {
+ classifyToolActivity,
+ collectToolFilePaths,
+ computerUseToolTitle,
+ formatReadToolLabel,
+ formatSearchToolLabel,
+} from "@t3tools/shared/toolActivity";
+import { formatDuration } from "@t3tools/shared/orchestrationTiming";
+import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
+import * as DateTime from "effect/DateTime";
-export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests";
+export type PendingApproval = ThreadPendingApproval;
+export type PendingUserInput = ThreadPendingUserInput;
export interface PendingUserInputDraftAnswer {
readonly selectedOptionValues?: ReadonlyArray;
@@ -51,7 +69,8 @@ export interface PendingUserInputDraftAnswer {
export interface ThreadFeedActivity {
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
+ readonly runId: RunId | null;
+ readonly attemptId: RunAttemptId | null;
readonly summary: string;
readonly detail: string | null;
readonly canExpand: boolean;
@@ -61,76 +80,60 @@ export interface ThreadFeedActivity {
| "agent"
| "alert"
| "browser"
+ | "computer"
| "check"
| "command"
- | "computer"
| "edit"
| "eye"
| "globe"
+ | "search"
| "hammer"
| "lock"
| "message"
| "warning"
| "wrench"
| "zap";
+ readonly logo: T3McpToolLogo | null;
readonly toolLike: boolean;
+ readonly prominent: boolean;
readonly status: "success" | "failure" | "neutral" | null;
- readonly lifecycleStatus?: WorkLogToolLifecycleStatus;
- readonly workEntry: WorkLogEntry;
+ readonly lifecycleStatus: WorkLogToolLifecycleStatus;
+ readonly workEntry: WorkLogPresentationEntry;
readonly groupedToolDetail?: boolean;
readonly live?: boolean;
-}
-
-export interface WorkLogEntry {
- readonly questionAnswer?: UserInputAttachmentAnswerPayload;
- id: string;
- createdAt: string;
- turnId: TurnId | null;
- label: string;
- detail?: string;
- viewedImagePath?: string;
- command?: string;
- rawCommand?: string;
- changedFiles?: ReadonlyArray;
- tone: "thinking" | "tool" | "info" | "error";
- toolTitle?: string;
- toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
- toolIcon?: import("@t3tools/contracts").ToolActivityIcon;
- toolSource?: import("@t3tools/contracts").ToolActivitySource;
- itemType?: ToolLifecycleItemType;
- requestKind?: PendingApproval["requestKind"];
- toolLifecycleStatus?: WorkLogToolLifecycleStatus;
- sourceActivityKind?: OrchestrationThreadActivity["kind"];
- toolCallId?: string;
- /**
- * One row per workflow run or per-turn batch of direct spawns, like web's
- * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row
- * also carries each agent's terminal state to derive its status label.
- */
- agentSpawn?: {
- readonly workflowId: string | null;
- readonly agentTaskIds: ReadonlyArray;
- readonly agents: ReadonlyArray<{
- readonly title: string;
- readonly status: WorkLogToolLifecycleStatus | undefined;
- readonly detail: string | undefined;
- /** When this member last reported, so the card can show the newest activity. */
- readonly updatedAt: string;
- }>;
- };
- toolData?: unknown;
-}
-
-interface DerivedWorkLogEntry extends WorkLogEntry {
- sourceActivityKind: OrchestrationThreadActivity["kind"];
- collapseKey?: string;
- /** Grouping key for subagent lifecycle rows (one row per agent). */
- taskId?: string;
- /** The tool call that launched this agent, when the provider reports one. */
- agentSpawnToolCallId?: string;
- isWorkflowCoordinator?: boolean;
- /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */
- isBackgroundTask?: boolean;
+ readonly projectedItem: OrchestrationV2ProjectedTurnItem;
+}
+
+export interface ThreadFeedMessage {
+ readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined;
+ readonly id: MessageId;
+ readonly role: "user" | "assistant";
+ readonly text: string;
+ readonly attachments: ReadonlyArray;
+ readonly runId: RunId | null;
+ readonly streaming: boolean;
+ readonly inputIntent?: OrchestrationV2UserMessageInputIntent;
+ readonly createdBy?: OrchestrationV2Actor;
+ readonly creationSource?: OrchestrationV2CreationSource;
+ readonly scheduledTaskId?: ScheduledTaskId;
+ readonly senderThreadId?: ThreadId;
+ readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
+ readonly sourceThreadId: ThreadId;
+ readonly createdAt: string;
+ readonly updatedAt: string;
+ readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
+}
+
+/** A message shown before the server has it: a pending creation or local feedback. */
+export interface LocalThreadMessage {
+ readonly id: MessageId;
+ readonly role: string;
+ readonly text: string;
+ readonly context?: OrchestrationMessageContext | undefined;
+ readonly attachments?: ReadonlyArray | undefined;
+ readonly streaming: boolean;
+ readonly createdAt: string;
+ readonly updatedAt: string;
}
type RawThreadFeedEntry =
@@ -138,83 +141,68 @@ type RawThreadFeedEntry =
readonly type: "message";
readonly id: string;
readonly createdAt: string;
- readonly message: OrchestrationThread["messages"][number];
+ readonly message: ThreadFeedMessage;
}
| {
readonly type: "activity";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
+ readonly runId: RunId | null;
readonly activity: ThreadFeedActivity;
};
-export type ThreadFeedEntry =
- | (Extract & {
- readonly reasoningMessages?: OrchestrationThread["messages"];
- })
+export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean };
+
+type ThreadFeedEntryContent =
+ | Extract
| {
readonly type: "activity-group";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
+ readonly runId: RunId | null;
readonly activities: ReadonlyArray;
}
| {
readonly type: "work-toggle";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
+ readonly runId: RunId | null;
readonly groupId: string;
readonly hiddenCount: number;
readonly expanded: boolean;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
- readonly toolSurface?: WorkLogEntry["toolSurface"];
- readonly toolIcon?: WorkLogEntry["toolIcon"];
- readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain";
+ readonly toolSurface?: WorkLogPresentationEntry["toolSurface"];
+ readonly toolIcon?: WorkLogPresentationEntry["toolIcon"];
+ readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
readonly hasFailure: boolean;
readonly live: boolean;
readonly shimmer: boolean;
}
| {
- readonly type: "turn-fold";
+ readonly type: "run-fold";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId;
+ readonly runId: RunId;
readonly label: string;
readonly expanded: boolean;
}
| {
- /**
- * The turn's single live slot. Web keys its live tool row and its
- * "Thinking" row identically so the slot updates in place; here the
- * slot holds "Thinking" whenever no tool row is shimmering, so a tool
- * failing does not insert a row under the group it lives in.
- */
readonly type: "thinking";
readonly id: string;
readonly createdAt: string;
- readonly turnId: TurnId | null;
- }
- | {
- /**
- * One batch of spawned subagents. Rendered as its own card because a
- * single-line tool row has no room for what the agents are doing now,
- * which on a phone is the one thing worth showing.
- */
- readonly type: "agent-spawn";
- readonly id: string;
- readonly createdAt: string;
- readonly turnId: TurnId | null;
- readonly activity: ThreadFeedActivity;
- readonly expanded: boolean;
- readonly summary: AgentSpawnSummary;
+ readonly runId: RunId | null;
};
+export interface ThreadFeedLatestRun {
+ readonly runId: RunId;
+ readonly status: OrchestrationV2RunStatus;
+ readonly startedAt: string | null;
+ readonly completedAt: string | null;
+}
+
export interface AgentSpawnSummary {
- /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */
readonly title: string;
- /** Latest member activity while working, else the batch outcome. */
readonly status: string;
readonly tone: "working" | "completed" | "failed" | "stopped";
readonly members: ReadonlyArray<{
@@ -226,56 +214,95 @@ export interface AgentSpawnSummary {
}>;
}
-export type ThreadFeedLatestTurn = Pick<
- OrchestrationLatestTurn,
- "turnId" | "state" | "startedAt" | "completedAt"
->;
+function compactWorkEntryText(value: string): string {
+ return value.replace(/\s+/gu, " ").trim();
+}
+
+/** Expanded work rows keep their detail while compact rows show a stable one-line label. */
+export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = false): string {
+ if (expanded && entry.itemType === "reasoning")
+ return entry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought";
+ const presentation = resolveWorkEntryToolPresentation(entry);
+ if (presentation) return presentation.displayName;
+ if (entry.command?.trim()) return compactWorkEntryText(commandDisplayText(entry.command));
+ const action = toolGroupAction(entry);
+ const isToolRead = action === "read" && entry.itemType === "dynamic_tool";
+ if (action === "code-search" || action === "search") {
+ const toolData =
+ entry.toolData !== null &&
+ typeof entry.toolData === "object" &&
+ !Array.isArray(entry.toolData)
+ ? (entry.toolData as Record)
+ : undefined;
+ // Adapters title file searches with their target; the item keeps only the pattern.
+ const searchLabel =
+ entry.itemType === "file_search" ? entry.label : formatSearchToolLabel(toolData);
+ if (searchLabel) return searchLabel;
+ }
+ if (isToolRead) {
+ const [firstPath] = entry.changedFiles ?? collectToolFilePaths(entry.toolData);
+ if (firstPath) {
+ return formatReadToolLabel(firstPath, Math.max(0, (entry.changedFiles?.length ?? 1) - 1));
+ }
+ if (!expanded) return "Read file";
+ }
+ const preview =
+ entry.command ??
+ (isToolRead || (!expanded && (action === "code-search" || action === "search"))
+ ? null
+ : entry.detail) ??
+ (entry.changedFiles?.length
+ ? entry.changedFiles.length === 1
+ ? entry.changedFiles[0]!
+ : `${entry.changedFiles[0]!} +${entry.changedFiles.length - 1} more`
+ : null);
+ if (expanded) return preview?.trim() || entry.label;
+ return preview ? compactWorkEntryText(preview) || entry.label : entry.label;
+}
type ThreadFeedActivityGroup = Extract;
-// These keys are immutable inputs. Weak caches release old histories with their source data.
-const activityEntriesCache = new WeakMap<
- ReadonlyArray,
- ReadonlyArray>
+// Immutable source rows let retained history keep its identities while the active item streams.
+const projectedEntriesCache = new WeakMap<
+ OrchestrationV2ProjectedTurnItem,
+ {
+ readonly attemptId: RunAttemptId | null;
+ readonly entry: RawThreadFeedEntry;
+ }
>();
-const messageEntriesCache = new WeakMap<
- OrchestrationThread["messages"][number],
+const localMessageEntriesCache = new WeakMap<
+ LocalThreadMessage,
Extract
>();
const activityGroupsCache = new WeakMap();
+const failedActivityGroupsCache = new WeakMap<
+ ThreadFeedActivityGroup,
+ ReadonlyArray
+>();
const presentedActivityGroupsCache = new WeakMap<
ThreadFeedActivityGroup,
{
- readonly unsettledTurnId: TurnId | null;
+ readonly activeRunId: RunId | null;
readonly isWorking: boolean;
readonly activeTail: boolean;
readonly rows: ReadonlyArray;
}
>();
-const turnFoldRowsCache = new WeakMap<
+const runFoldRowsCache = new WeakMap<
ThreadFeedEntry,
- Extract
+ Extract
>();
let cachedThinkingRow: Extract | null = null;
-const reasoningGroupsCache = new WeakMap<
- ThreadFeedEntry,
- Extract
->();
-const activityRunsCache = new WeakMap<
- ThreadFeedEntry,
- {
- readonly source: ReadonlyArray;
- readonly state: string;
- readonly rows: ThreadFeedEntry[];
- }
->();
-export function isContextCompactionActivityGroup(
- entry: Extract,
-): boolean {
+export function isContextCompactionActivityGroup(entry: ThreadFeedActivityGroup): boolean {
+ return (
+ entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "compaction"
+ );
+}
+
+export function isContextHandoffActivityGroup(entry: ThreadFeedActivityGroup): boolean {
return (
- entry.activities.length === 1 &&
- entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction"
+ entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "handoff"
);
}
@@ -284,15 +311,13 @@ function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
}
function normalizeDraftAnswer(value: string | undefined): string | null {
- if (typeof value !== "string") {
- return null;
- }
+ if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function resolvePendingUserInputOptionValue(
- question: UserInputQuestion,
+ question: ThreadUserInputQuestion,
value: string,
): string | null {
if (question.options.some((option) => option.value === value)) {
@@ -307,7 +332,7 @@ function resolvePendingUserInputOptionValue(
}
function normalizeSelectedOptionValues(
- question: UserInputQuestion,
+ question: ThreadUserInputQuestion,
value: ReadonlyArray | undefined,
): ReadonlyArray {
if (!Array.isArray(value)) {
@@ -324,7 +349,7 @@ function normalizeSelectedOptionValues(
}
function resolvePendingUserInputAnswer(
- question: UserInputQuestion,
+ question: ThreadUserInputQuestion,
draft: PendingUserInputDraftAnswer | undefined,
): string | ReadonlyArray | null {
if (draft?.attachmentsBlocked) return null;
@@ -348,1201 +373,446 @@ function resolvePendingUserInputAnswer(
);
}
-/** Some providers settle agents through task.updated instead of task.completed. */
-const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet = new Set([
- "completed",
- "failed",
- "cancelled",
- "interrupted",
-]);
-
-function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean {
- if (activity.kind !== "task.updated") {
- return false;
- }
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- return (
- typeof payload?.status === "string" &&
- (MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) ||
- (payload.timelineBypass === true && payload.status === "idle"))
- );
-}
-
-/**
- * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal
- * activity lives in the Agents sheet, not the work log. Agent lifecycle rows
- * pass even when bypassed or owned by another agent, because they fold into
- * their spawn batch rather than rendering on their own; that is how Codex
- * children (all bypassed) and Claude workflow members reach the batch row.
- * Terminal rows are kept regardless — with no Agents surface on mobile they
- * are the terminal signal.
- */
-function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- if (!payload) {
- return false;
- }
- const isTaskRow =
- activity.kind === "task.started" ||
- activity.kind === "task.progress" ||
- activity.kind === "task.updated" ||
- activity.kind === "task.completed";
- const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
- if (isTaskRow) {
- if (!ownedByAgent && payload.timelineBypass !== true) {
- return false;
- }
- // An agent's own shells stay internal; the agents themselves fold into
- // their batch. A bypassed batch marker keeps its terminal row.
- if (typeof payload.taskId === "string" && payload.agentKind === "agent") {
- return false;
- }
- if (ownedByAgent) {
- return true;
- }
- return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity));
- }
- return payload.timelineBypass === true || ownedByAgent;
-}
-
-/** Agent (non-background) task.started rows seed spawn batches. */
-function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- return typeof payload?.taskId === "string" && payload.agentKind === "agent";
-}
-
-function deriveWorkLogEntries(
- activities: ReadonlyArray,
-): DerivedWorkLogEntry[] {
- const ordered = Arr.sort(activities, activityOrder);
- const entries: DerivedWorkLogEntry[] = [];
- for (const activity of foldUserInputActivities(ordered)) {
- // The setup card owns its snapshot, including failed and cancelled outcomes.
- if (
- isWorktreeSetupActivity(activity.kind) &&
- (activity.tone !== "error" || activity.kind === "worktree-setup")
- )
- continue;
- if (activity.kind === "tool.started") continue;
- // Like web: an agent's task.started row anchors its batch. It has a fixed
- // id and timestamp, unlike progress ticks, whose stable per-task id is
- // rewritten with a new createdAt on every update (and would otherwise
- // make the batch row a "fresh" row again on each tick).
- if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue;
- if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue;
- if (activity.kind === "tool.progress") continue;
- if (activity.kind === "context-window.updated") continue;
- if (activity.summary === "Checkpoint captured") continue;
- if (isNoContentRuntimeWarning(activity)) continue;
- if (isPlanBoundaryToolActivity(activity)) continue;
- if (isAgentInternalActivity(activity)) continue;
- entries.push(toDerivedWorkLogEntry(activity));
- }
- return collapseDerivedWorkLogEntries(entries);
-}
-
-/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
- * commands_changed, ...) as runtime warnings. The suffix comes from
- * describeUnknownSdkMessage in the Claude adapter; a row with no displayable
- * text carries nothing a user can act on, so it does not render. */
-function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
- return (
- activity.kind === "runtime.warning" &&
- activity.summary.endsWith("(no displayable text content)")
- );
-}
-
-function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
- if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
- return false;
- }
-
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
-}
-
-const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
-
-function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
- const payload =
- activity.payload && typeof activity.payload === "object"
- ? (activity.payload as Record)
- : null;
- const commandPreview = extractToolCommand(payload);
- const changedFiles = extractChangedFiles(payload);
- const title = extractToolTitle(payload);
- const toolPresentation = extractToolActivityPresentation(payload);
- // Terminal task updates carry identity so they replace each child's progress row.
- const isTaskActivity =
- activity.kind === "task.started" ||
- activity.kind === "task.progress" ||
- activity.kind === "task.completed" ||
- activity.kind === "task.updated";
- const taskSummary =
- isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
- ? payload.summary
- : null;
- const taskDetailAsLabel =
- isTaskActivity &&
- !taskSummary &&
- !title &&
- typeof payload?.detail === "string" &&
- payload.detail.length > 0
- ? payload.detail
- : null;
- const taskLabel = taskSummary || taskDetailAsLabel;
- const taskId =
- isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0
- ? payload.taskId
- : undefined;
- const entry: DerivedWorkLogEntry = {
- id: activity.id,
- createdAt: activity.createdAt,
- turnId: activity.turnId,
- ...(taskId ? { taskId } : {}),
- label: taskLabel || activity.summary,
- tone:
- activity.kind === "task.progress"
- ? "thinking"
- : activity.tone === "approval"
- ? "info"
- : activity.tone,
- sourceActivityKind: activity.kind,
- ...(() => {
- if (activity.kind !== "user-input.answer-submitted") return {};
- const answer = decodeQuestionAttachmentAnswer(activity.payload);
- return Option.isSome(answer) ? { questionAnswer: answer.value } : {};
- })(),
- };
- const toolCallId =
- asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);
- if (toolCallId) {
- entry.toolCallId = toolCallId;
- }
- if (isTaskActivity && payload) {
- if (payload.agentKind !== "agent") {
- entry.isBackgroundTask = true;
- }
- const spawnToolCallId = asTrimmedString(payload.toolUseId);
- if (spawnToolCallId) {
- entry.agentSpawnToolCallId = spawnToolCallId;
- }
- if (
- payload.taskType === "local_workflow" ||
- (typeof payload.workflowName === "string" && payload.workflowName.length > 0)
- ) {
- entry.isWorkflowCoordinator = true;
- }
- }
- const itemType = extractWorkLogItemType(payload);
- const requestKind = extractWorkLogRequestKind(payload);
- const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath);
- const commandOutput = commandPreview.command ? extractCommandOutputText(payload?.data) : null;
- const output = commandOutput ? stripTrailingExitCode(commandOutput).output : null;
- if (!taskDetailAsLabel && output) {
- entry.detail = output;
- } else if (!taskDetailAsLabel && typeof payload?.detail === "string") {
- const detail = stripTrailingExitCode(payload.detail).output;
- const data = asRecord(payload.data);
- const repeatsCommand =
- detail !== null &&
- commandDetailRepeatsCommand({
- detail,
- command: commandPreview.command,
- rawCommand: commandPreview.rawCommand,
- toolName: data?.toolName,
- data,
- });
- if (detail && detail !== title && !repeatsCommand) entry.detail = detail;
- }
- if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) {
- entry.detail = payload.error;
- }
- if (!entry.detail && (activity.kind === "runtime.error" || activity.kind === "runtime.warning")) {
- const message = asTrimmedString(payload?.message);
- if (message) entry.detail = message;
- }
- if (viewedImagePath) {
- entry.viewedImagePath = viewedImagePath;
- }
- if (commandPreview.command) {
- entry.command = commandPreview.command;
- }
- if (commandPreview.rawCommand) {
- entry.rawCommand = commandPreview.rawCommand;
- }
- if (changedFiles.length > 0) {
- entry.changedFiles = changedFiles;
- }
- if (title) {
- entry.toolTitle = title;
- }
- if (toolPresentation.toolSurface) {
- entry.toolSurface = toolPresentation.toolSurface;
- }
- if (toolPresentation.toolIcon) {
- entry.toolIcon = toolPresentation.toolIcon;
- }
- if (toolPresentation.toolSource) {
- entry.toolSource = toolPresentation.toolSource;
- }
- if (itemType === "mcp_tool_call") {
- const data = asRecord(payload?.data);
- const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item;
- if (toolData !== undefined) {
- entry.toolData = toolData;
- }
- }
- if (itemType) {
- entry.itemType = itemType;
- }
- if (requestKind) {
- entry.requestKind = requestKind;
- }
- let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
- if (
- !toolLifecycleStatus &&
- (activity.kind === "tool.completed" || activity.kind === "task.completed")
- ) {
- toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed";
- }
- // A Codex child that finishes its turn reports "idle" (resumable, not
- // terminal). For the batch row that is a finished member.
- if (!toolLifecycleStatus && isTaskActivity && payload?.status === "idle") {
- toolLifecycleStatus = "completed";
- }
- if (toolLifecycleStatus) {
- entry.toolLifecycleStatus = toolLifecycleStatus;
- }
- const collapseKey = deriveToolLifecycleCollapseKey(entry);
- if (collapseKey) {
- entry.collapseKey = collapseKey;
- }
- return entry;
-}
-
-/**
- * Spawn-group key for a subagent lifecycle row. Workflow members and their
- * coordinator share the coordinator's group; direct spawns batch per turn.
- * Same keys as web's session-logic so both clients fold the same rows.
- */
-function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
- const taskId = entry.taskId ?? "";
- const workflowSlot = taskId.indexOf(":wf:");
- if (workflowSlot !== -1) return `wf:${taskId.slice(0, workflowSlot)}`;
- if (entry.isWorkflowCoordinator) return `wf:${taskId}`;
- return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
-}
-
-/**
- * The batch row keeps the group's anchor identity (id, createdAt, turnId,
- * label) so it renders where the run launched instead of drifting to the
- * newest progress tick, and gains each member's latest lifecycle state.
- */
-function agentSpawnRow(
- anchor: DerivedWorkLogEntry,
- workflowId: string | null,
- agentTaskIds: ReadonlyArray,
- members: NonNullable["agents"],
-): DerivedWorkLogEntry {
- // A finished coordinator settles members that never reported their own
- // end; Claude stops synthesizing member ticks once the workflow is done.
- const coordinator = workflowId === null ? undefined : members[agentTaskIds.indexOf(workflowId)];
- const agents =
- coordinator?.status !== undefined && coordinator.status !== "inProgress"
- ? members.map((agent) =>
- agent.status === undefined || agent.status === "inProgress"
- ? { ...agent, status: coordinator.status }
- : agent,
- )
- : members;
- const agentSpawn = { workflowId, agentTaskIds, agents };
- // The batch row has no detail of its own: its body lists the members.
- const { detail: _detail, ...anchorWithoutDetail } = anchor;
- return {
- ...anchorWithoutDetail,
- // The row's own lifecycle is the batch's: live while any member is, then
- // the worst terminal state, so the group summary and shimmer follow it.
- toolLifecycleStatus: agentSpawnLifecycleStatus(agents),
- agentSpawn,
- };
-}
-
-function agentSpawnMember(
- entry: DerivedWorkLogEntry,
- previous?: NonNullable["agents"][number],
-) {
- return {
- title: entry.toolTitle ?? previous?.title ?? entry.label,
- status: entry.toolLifecycleStatus ?? previous?.status,
- detail: entry.detail ?? previous?.detail,
- updatedAt: entry.createdAt,
- };
-}
-
-function mergeAgentSpawnEntries(
- existing: DerivedWorkLogEntry,
- entry: DerivedWorkLogEntry,
-): DerivedWorkLogEntry {
- const spawn = existing.agentSpawn!;
- const taskId = entry.taskId ?? "";
- const memberIndex = spawn.agentTaskIds.indexOf(taskId);
- if (memberIndex === -1) {
- return agentSpawnRow(
- existing,
- spawn.workflowId,
- [...spawn.agentTaskIds, taskId],
- [...spawn.agents, agentSpawnMember(entry)],
- );
- }
- const agents = spawn.agents.map((agent, index) =>
- index === memberIndex ? agentSpawnMember(entry, agent) : agent,
- );
- return agentSpawnRow(existing, spawn.workflowId, spawn.agentTaskIds, agents);
-}
-
-function agentSpawnLifecycleStatus(
- agents: NonNullable["agents"],
-): WorkLogToolLifecycleStatus {
- const statuses = agents.map((agent) => agent.status);
- if (statuses.some((status) => status === undefined || status === "inProgress")) {
- return "inProgress";
- }
- if (statuses.includes("failed")) return "failed";
- if (statuses.includes("declined")) return "declined";
- if (statuses.includes("stopped")) return "stopped";
- return "completed";
-}
-
-function collapseDerivedWorkLogEntries(
- entries: ReadonlyArray,
-): DerivedWorkLogEntry[] {
- const collapsed: DerivedWorkLogEntry[] = [];
- // Task rows collapse by identity, not adjacency (quiet-timeline guarantee;
- // mirrors web's session-logic). Background tasks keep one row per taskId;
- // agent spawns fold into one row per spawn group, decided at the FIRST row
- // seen for a taskId because later rows can arrive under synthetic turns.
- const taskRowIndex = new Map();
- const spawnRowIndex = new Map();
- const spawnGroupByTaskId = new Map();
- const toolLifecycleRowIndex = new Map();
- // Tool calls that launched an agent (Claude's Agent tool, ACP subagent
- // calls). The batch card is the whole story of that call, so its own
- // lifecycle row is dropped.
- const spawnToolCallIds = new Set(
- entries.flatMap((entry) =>
- entry.agentSpawnToolCallId !== undefined ? [entry.agentSpawnToolCallId] : [],
- ),
- );
- for (const entry of entries) {
- if (
- entry.toolCallId !== undefined &&
- entry.taskId === undefined &&
- spawnToolCallIds.has(entry.toolCallId)
- ) {
- continue;
- }
- const isTaskRow =
- entry.taskId !== undefined &&
- (entry.sourceActivityKind === "task.started" ||
- entry.sourceActivityKind === "task.progress" ||
- entry.sourceActivityKind === "task.completed" ||
- entry.sourceActivityKind === "task.updated");
- if (isTaskRow && entry.taskId !== undefined) {
- if (entry.isBackgroundTask) {
- const existingIndex = taskRowIndex.get(entry.taskId);
- if (existingIndex !== undefined) {
- collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
- continue;
- }
- taskRowIndex.set(entry.taskId, collapsed.length);
- collapsed.push(entry);
- continue;
- }
- const groupKey = spawnGroupByTaskId.get(entry.taskId) ?? agentSpawnGroupKey(entry);
- spawnGroupByTaskId.set(entry.taskId, groupKey);
- const existingIndex = spawnRowIndex.get(groupKey);
- if (existingIndex !== undefined) {
- collapsed[existingIndex] = mergeAgentSpawnEntries(collapsed[existingIndex]!, entry);
- continue;
- }
- spawnRowIndex.set(groupKey, collapsed.length);
- collapsed.push(
- agentSpawnRow(
- entry,
- groupKey.startsWith("wf:") ? groupKey.slice(3) : null,
- [entry.taskId],
- [agentSpawnMember(entry)],
- ),
- );
- continue;
- }
- const lifecycleKey = toolLifecycleCollapseMapKey(entry);
- if (lifecycleKey !== undefined) {
- const matchingIndex = toolLifecycleRowIndex.get(lifecycleKey);
- const matchingEntry = matchingIndex === undefined ? undefined : collapsed[matchingIndex];
- if (
- matchingIndex !== undefined &&
- matchingEntry &&
- shouldCollapseToolLifecycleEntries(matchingEntry, entry)
- ) {
- collapsed[matchingIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry);
- continue;
- }
- toolLifecycleRowIndex.delete(lifecycleKey);
- }
- const previous = collapsed.at(-1);
- if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
- const previousIndex = collapsed.length - 1;
- const previousKey = toolLifecycleCollapseMapKey(previous);
- if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey);
- const merged = mergeDerivedWorkLogEntries(previous, entry);
- collapsed[previousIndex] = merged;
- const mergedKey = toolLifecycleCollapseMapKey(merged);
- if (mergedKey !== undefined) toolLifecycleRowIndex.set(mergedKey, previousIndex);
- continue;
- }
- collapsed.push(entry);
- if (lifecycleKey !== undefined) {
- toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
- }
- }
- return collapsed;
-}
-
-function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined {
- if (
- entry.sourceActivityKind !== "tool.updated" &&
- entry.sourceActivityKind !== "tool.completed"
- ) {
- return undefined;
- }
- return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined;
-}
-
-function shouldCollapseToolLifecycleEntries(
- previous: DerivedWorkLogEntry,
- next: DerivedWorkLogEntry,
-): boolean {
- if (
- previous.sourceActivityKind !== "tool.updated" &&
- previous.sourceActivityKind !== "tool.completed"
- ) {
- return false;
- }
- if (next.sourceActivityKind !== "tool.updated" && next.sourceActivityKind !== "tool.completed") {
- return false;
- }
- if (previous.turnId !== next.turnId) {
- return false;
- }
- if (previous.sourceActivityKind === "tool.completed") {
- return false;
- }
- if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) {
- return true;
- }
- return (
- previous.toolCallId !== undefined &&
- next.toolCallId === undefined &&
- previous.itemType === next.itemType &&
- normalizeCompactToolLabel(previous.toolTitle ?? previous.label) ===
- normalizeCompactToolLabel(next.toolTitle ?? next.label)
- );
-}
-
-function mergeDerivedWorkLogEntries(
- previous: DerivedWorkLogEntry,
- next: DerivedWorkLogEntry,
-): DerivedWorkLogEntry {
- const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
- const detail = next.detail ?? previous.detail;
- const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath;
- const command = next.command ?? previous.command;
- const rawCommand = next.rawCommand ?? previous.rawCommand;
- const toolTitle = next.toolTitle ?? previous.toolTitle;
- const toolSurface = next.toolSurface ?? previous.toolSurface;
- const toolIcon = next.toolIcon ?? previous.toolIcon;
- const toolSource = next.toolSource ?? previous.toolSource;
- const itemType = next.itemType ?? previous.itemType;
- const requestKind = next.requestKind ?? previous.requestKind;
- const collapseKey = next.collapseKey ?? previous.collapseKey;
- const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
- const toolCallId = next.toolCallId ?? previous.toolCallId;
- const toolData = next.toolData ?? previous.toolData;
- return {
- ...previous,
- ...next,
- id: previous.id,
- createdAt: previous.createdAt,
- ...(detail ? { detail } : {}),
- ...(viewedImagePath ? { viewedImagePath } : {}),
- ...(command ? { command } : {}),
- ...(rawCommand ? { rawCommand } : {}),
- ...(changedFiles.length > 0 ? { changedFiles } : {}),
- ...(toolTitle ? { toolTitle } : {}),
- ...(toolSurface ? { toolSurface } : {}),
- ...(toolIcon ? { toolIcon } : {}),
- ...(toolSource ? { toolSource } : {}),
- ...(itemType ? { itemType } : {}),
- ...(requestKind ? { requestKind } : {}),
- ...(collapseKey ? { collapseKey } : {}),
- ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
- ...(toolCallId ? { toolCallId } : {}),
- ...(toolData !== undefined ? { toolData } : {}),
- };
-}
-
-function mergeChangedFiles(
- previous: ReadonlyArray | undefined,
- next: ReadonlyArray | undefined,
-): string[] {
- const merged = [...(previous ?? []), ...(next ?? [])];
- if (merged.length === 0) {
- return [];
- }
- return [...new Set(merged)];
-}
-
-function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {
- if (
- entry.sourceActivityKind !== "tool.updated" &&
- entry.sourceActivityKind !== "tool.completed"
- ) {
- return undefined;
- }
- if (entry.toolCallId) {
- return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`;
- }
- const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
- const detail = entry.detail?.trim() ?? "";
- const itemType = entry.itemType ?? "";
- if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) {
- return undefined;
- }
- return [itemType, normalizedLabel, detail].join("\u001f");
-}
-
-function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
- if (entry.agentSpawn) {
- switch (entry.toolLifecycleStatus) {
- case "failed":
- return "failure";
- case "completed":
- return "success";
- default:
- return "neutral";
- }
- }
- if (!workLogEntryIsToolLike(entry)) {
- return null;
- }
- if (workEntryIndicatesToolFailure(entry)) {
- return "failure";
- }
- if (workEntryIndicatesToolSuccess(entry)) {
- return "success";
- }
- return "neutral";
-}
-
-function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
- if (entry.agentSpawn) return "agent";
- if (
- entry.questionAnswer ||
- entry.sourceActivityKind === "user-input.requested" ||
- entry.sourceActivityKind === "user-input.resolved"
- ) {
- return "message";
- }
- if (entry.sourceActivityKind === "runtime.warning") return "warning";
- if (entry.toolSurface) return entry.toolSurface;
- if (entry.requestKind === "command") return "command";
- if (entry.requestKind === "file-read") return "eye";
- if (entry.requestKind === "file-change") return "edit";
- if (entry.requestKind === "permission") return "lock";
- if (entry.itemType === "command_execution" || entry.command) return "command";
- if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit";
- if (entry.itemType === "web_search") return "globe";
- if (entry.itemType === "image_view") return "eye";
- if (entry.itemType === "mcp_tool_call") return "wrench";
- if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") {
- return "hammer";
- }
- if (entry.tone === "error") return "alert";
- if (entry.tone === "thinking") return "agent";
- if (entry.tone === "info") return "check";
- return "zap";
-}
-
-function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
- if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn);
- const blocks: string[] = [];
- const visibleLabel = workEntryRowLabel(entry, true).trim();
- const appendBlock = (value: string | null | undefined) => {
- const trimmed = value?.trim();
- if (trimmed && (entry.command || (trimmed !== visibleLabel && !blocks.includes(trimmed)))) {
- blocks.push(trimmed);
- }
- };
-
- if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) {
- appendBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);
- }
- appendBlock(entry.rawCommand ?? entry.command);
- appendBlock(entry.detail);
- if ((entry.changedFiles?.length ?? 0) > 0) {
- appendBlock(entry.changedFiles!.join("\n"));
- }
-
- return blocks.length > 0 ? blocks.join("\n\n") : null;
-}
-
-/**
- * Even single-line details can be truncated by the available screen width.
- * Cheap field checks come first so large tool payloads are not serialized
- * for every row (see the deferred-expansion test).
- */
-function workEntryCanExpand(entry: WorkLogEntry): boolean {
- if (entry.questionAnswer) return true;
- if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;
- if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;
- if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true;
- return Boolean((entry.rawCommand ?? entry.command)?.trim() || entry.detail?.trim());
-}
-
-function collapseWhitespace(value: string): string {
- return value.replace(/\s+/g, " ").trim();
-}
-
-function stripShellWrapper(value: string): string {
- const trimmed = value.trim();
- const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/);
- return (match?.[1] ?? trimmed).trim();
-}
-
-/** Expanded rows retain detail formatting; commands stay in the separate body. */
-export function workEntryRowLabel(entry: WorkLogEntry, expanded = false): string {
- if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn);
- const presentation = resolveWorkEntryToolPresentation(entry);
- if (presentation) return presentation.displayName;
- if (expanded && entry.command?.trim()) return "Command";
- const preview = workEntryPreview(entry);
- if (expanded) return preview?.trim() || workEntryHeading(entry);
- const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview));
- return compactPreview || workEntryHeading(entry);
-}
-
-function memoizeValue(build: () => T): () => T {
- let value: T;
- let initialized = false;
- return () => {
- if (!initialized) {
- value = build();
- initialized = true;
- }
- return value;
- };
-}
-
-function workEntryPreview(
- workEntry: Pick,
-): string | null {
- if (workEntry.command) return workEntry.command;
- if (workEntry.detail) return workEntry.detail;
- if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
- const [firstPath] = workEntry.changedFiles ?? [];
- if (!firstPath) return null;
- return workEntry.changedFiles!.length === 1
- ? firstPath
- : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
-}
-
function capitalizePhrase(value: string): string {
const trimmed = value.trim();
- if (trimmed.length === 0) {
- return value;
- }
- return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
-}
-
-/**
- * Batch label for a spawn row, matching web's CTA wording. Web reads live
- * agent state from its Agents panel; mobile has only the lifecycle states
- * folded into the row, so "working" means a member has not reported a
- * terminal state yet.
- */
-export function agentSpawnLabel(spawn: NonNullable): string {
- const members = agentSpawnMembers(spawn);
- const count = Math.max(members.length, 1);
- const subjects = `${count} subagent${count === 1 ? "" : "s"}`;
- const working = members.filter(
- (agent) => agent.status === undefined || agent.status === "inProgress",
- ).length;
- const failed = members.filter((agent) => agent.status === "failed").length;
- const stopped = members.filter((agent) => agent.status === "stopped").length;
- if (working > 0) {
- return `Kicked off ${subjects} · ${working} working`;
- }
- const status = failed > 0 ? `${failed} failed` : stopped > 0 ? `${stopped} stopped` : "completed";
- return `Ran ${subjects} · ${status}`;
-}
-
-/** Workflow coordinators sit in their own batch but are not a member. */
-function agentSpawnMembers(spawn: NonNullable) {
- return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);
-}
-
-function agentSpawnTone(status: WorkLogToolLifecycleStatus | undefined): AgentSpawnSummary["tone"] {
- switch (status) {
- case undefined:
- case "inProgress":
- return "working";
- case "completed":
- return "completed";
- case "failed":
- case "declined":
- return "failed";
- case "stopped":
- return "stopped";
- }
-}
-
-/**
- * What the spawn card shows. While members work, the status line is the
- * newest member activity (its progress detail), so the card reads like the
- * live tool row does for a single call. Once every member settles, it is the
- * batch outcome in web's CTA wording.
- */
-export function agentSpawnSummary(
- spawn: NonNullable,
- batchStatus: WorkLogToolLifecycleStatus | undefined,
-): AgentSpawnSummary {
- const members = agentSpawnMembers(spawn).map((agent) => {
- const tone = agentSpawnTone(agent.status);
- return {
- title: agent.title,
- status: tone === "working" ? "working" : (agent.status ?? tone),
- tone,
- detail: agent.detail,
- updatedAt: agent.updatedAt,
- };
- });
- const tone = agentSpawnTone(batchStatus);
- // A workflow's coordinator is not a member; before any member reports the
- // batch has none.
- const title =
- members.length === 0
- ? "Subagents"
- : members.length === 1
- ? members[0]!.title
- : `${members.length} subagents`;
- if (tone === "working") {
- const working = members.filter((member) => member.tone === "working");
- const latest = working
- .filter((member) => member.detail !== undefined)
- .reduce<(typeof working)[number] | undefined>(
- (newest, member) =>
- newest === undefined || member.updatedAt > newest.updatedAt ? member : newest,
- undefined,
- );
- const status =
- latest?.detail ??
- (members.length > 1 ? `${working.length} of ${members.length} working` : "Working");
- return { title, status, tone, members };
- }
- // The batch tone covers a coordinator that failed or stopped on its own.
- const failed = members.filter((member) => member.tone === "failed").length;
- const stopped = members.filter((member) => member.tone === "stopped").length;
- const outcome =
- tone === "failed" || failed > 0
- ? `${members.length > 1 && failed > 0 ? `${failed} ` : ""}failed`
- : tone === "stopped" || stopped > 0
- ? `${members.length > 1 && stopped > 0 ? `${stopped} ` : ""}stopped`
- : "completed";
- return { title, status: outcome, tone, members };
-}
-
-function agentSpawnExpandedBody(spawn: NonNullable): string | null {
- const lines = agentSpawnMembers(spawn).map((agent) => {
- const status =
- agent.status === undefined || agent.status === "inProgress" ? "working" : agent.status;
- return `${agent.title} · ${status}${agent.detail ? `\n ${agent.detail}` : ""}`;
- });
- return lines.length > 0 ? lines.join("\n") : null;
-}
-
-function workEntryHeading(workEntry: WorkLogEntry): string {
- if (workEntry.agentSpawn) return agentSpawnLabel(workEntry.agentSpawn);
- const presentation = resolveWorkEntryToolPresentation(workEntry);
- if (presentation) return presentation.displayName;
- if (!workEntry.toolTitle) {
- return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
- }
- return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
-}
-
-function singleToolCallLabel(activity: ThreadFeedActivity): string {
- const presentation = resolveWorkEntryToolPresentation(activity.workEntry, "completed");
- if (presentation) return presentation.displayName;
- const command = activity.workEntry.command?.trim();
- return command || activity.summary;
-}
-
-function asRecord(value: unknown): Record | null {
- return value && typeof value === "object" ? (value as Record) : null;
-}
-
-function asTrimmedString(value: unknown): string | null {
- if (typeof value !== "string") {
- return null;
- }
- const trimmed = value.trim();
- return trimmed.length > 0 ? trimmed : null;
-}
-
-function trimMatchingOuterQuotes(value: string): string {
- const trimmed = value.trim();
- if (
- (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
- (trimmed.startsWith('"') && trimmed.endsWith('"'))
- ) {
- const unquoted = trimmed.slice(1, -1).trim();
- return unquoted.length > 0 ? unquoted : trimmed;
- }
- return trimmed;
-}
-
-function executableBasename(value: string): string | null {
- const trimmed = trimMatchingOuterQuotes(value);
- if (trimmed.length === 0) {
- return null;
- }
- const normalized = trimmed.replace(/\\/g, "/");
- const segments = normalized.split("/");
- const last = segments.at(-1)?.trim() ?? "";
- return last.length > 0 ? last.toLowerCase() : null;
-}
-
-function splitExecutableAndRest(value: string): { executable: string; rest: string } | null {
- const trimmed = value.trim();
- if (trimmed.length === 0) {
- return null;
- }
-
- if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
- const quote = trimmed.charAt(0);
- const closeIndex = trimmed.indexOf(quote, 1);
- if (closeIndex <= 0) {
- return null;
- }
- return {
- executable: trimmed.slice(0, closeIndex + 1),
- rest: trimmed.slice(closeIndex + 1).trim(),
- };
- }
-
- const firstWhitespace = trimmed.search(/\s/);
- if (firstWhitespace < 0) {
- return {
- executable: trimmed,
- rest: "",
- };
- }
-
- return {
- executable: trimmed.slice(0, firstWhitespace),
- rest: trimmed.slice(firstWhitespace).trim(),
- };
-}
-
-const SHELL_WRAPPER_SPECS = [
- {
- executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"],
- wrapperFlagPattern: /(?:^|\s)-command\s+/i,
- },
- {
- executables: ["cmd", "cmd.exe"],
- wrapperFlagPattern: /(?:^|\s)\/c\s+/i,
- },
- {
- executables: ["bash", "sh", "zsh"],
- wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i,
- },
-] as const;
-
-function findShellWrapperSpec(shell: string) {
- return SHELL_WRAPPER_SPECS.find((spec) =>
- (spec.executables as ReadonlyArray).includes(shell),
- );
-}
-
-function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null {
- const match = wrapperFlagPattern.exec(value);
- if (!match) {
- return null;
- }
-
- const command = value.slice(match.index + match[0].length).trim();
- if (command.length === 0) {
- return null;
- }
-
- const openingQuote = command[0];
- if ((openingQuote === "'" || openingQuote === '"') && !command.endsWith(openingQuote)) {
- return null;
- }
-
- const unwrapped = trimMatchingOuterQuotes(command);
- return unwrapped.length > 0 ? unwrapped : null;
-}
-
-function unwrapKnownShellCommandWrapper(value: string): string {
- const split = splitExecutableAndRest(value);
- if (!split || split.rest.length === 0) {
- return value;
- }
-
- const shell = executableBasename(split.executable);
- if (!shell) {
- return value;
- }
-
- const spec = findShellWrapperSpec(shell);
- if (!spec) {
- return value;
- }
-
- return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value;
-}
-
-function formatCommandArrayPart(value: string): string {
- return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
-}
-
-function formatCommandValue(value: unknown): string | null {
- const direct = asTrimmedString(value);
- if (direct) {
- return direct;
- }
- if (!Array.isArray(value)) {
- return null;
- }
- const parts: Array = [];
- for (const entry of value) {
- const part = asTrimmedString(entry);
- if (part !== null) {
- parts.push(part);
- }
- }
- if (parts.length === 0) {
- return null;
- }
- return parts.map((part) => formatCommandArrayPart(part)).join(" ");
-}
-
-function normalizeCommandValue(value: unknown): string | null {
- const formatted = formatCommandValue(value);
- return formatted ? unwrapKnownShellCommandWrapper(formatted) : null;
-}
-
-function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null {
- const formatted = formatCommandValue(value);
- if (!formatted || normalizedCommand === null) {
- return null;
- }
- return formatted === normalizedCommand ? null : formatted;
-}
-
-function extractToolCommand(payload: Record | null): {
- command: string | null;
- rawCommand: string | null;
-} {
- const data = asRecord(payload?.data);
- const item = asRecord(data?.item);
- const itemResult = asRecord(item?.result);
- const itemInput = asRecord(item?.input);
- const itemType = asTrimmedString(payload?.itemType);
- const detail = asTrimmedString(payload?.detail);
- const candidates: unknown[] = [
- item?.command,
- itemInput?.command,
- itemResult?.command,
- data?.command,
- itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
- ];
-
- for (const candidate of candidates) {
- const command = normalizeCommandValue(candidate);
- if (!command) {
- continue;
- }
- return {
- command,
- rawCommand: toRawToolCommand(candidate, command),
- };
- }
-
- return {
- command: null,
- rawCommand: null,
- };
+ return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
}
-function extractToolTitle(payload: Record | null): string | null {
- return asTrimmedString(payload?.title);
+function memoizeValue(build: () => T): () => T {
+ let value: T;
+ let initialized = false;
+ return () => {
+ if (!initialized) {
+ value = build();
+ initialized = true;
+ }
+ return value;
+ };
}
-function stripTrailingExitCode(value: string): {
- output: string | null;
- exitCode?: number | undefined;
-} {
- const trimmed = value.trim();
- const match = /^(?