authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-29 17:00:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-29 17:00:54-07:00
logb3e8c0334b25238e2b55868a87bd6270e234b7de
treed74543d142243271c8dd48cb6c3d5ffbe54a21fb
parente3accd6e957910649a9cca1b3280c6bbbf638690
signature Signed by PGP key B5690EEEBB952194

T3 Code Mobile [WIP] (#2013)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius@macmini.local> Co-authored-by: Yash Singh <saiansh2525@gmail.com>

407 files changed, 55906 insertions(+), 7497 deletions(-)

.github/workflows/ci.yml+27
...@@ -82,6 +82,33 @@ jobs:...@@ -82,6 +82,33 @@ jobs:
82 test -f apps/desktop/dist-electron/preload.cjs82 test -f apps/desktop/dist-electron/preload.cjs
83 grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs83 grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs
8484
85 mobile_native_static_analysis:
86 name: Mobile Native Static Analysis
87 runs-on: blacksmith-12vcpu-macos-26
88 timeout-minutes: 10
89 steps:
90 - name: Checkout
91 uses: actions/checkout@v6
92
93 - name: Setup Bun
94 uses: oven-sh/setup-bun@v2
95 with:
96 bun-version-file: package.json
97
98 - name: Setup Node
99 uses: actions/setup-node@v6
100 with:
101 node-version-file: package.json
102
103 - name: Install dependencies
104 run: bun install --frozen-lockfile
105
106 - name: Install mobile native static analysis tools
107 run: brew bundle install --file apps/mobile/Brewfile
108
109 - name: Lint mobile native sources
110 run: node scripts/mobile-native-static-check.ts
111
85 release_smoke:112 release_smoke:
86 name: Release Smoke113 name: Release Smoke
87 runs-on: blacksmith-8vcpu-ubuntu-2404114 runs-on: blacksmith-8vcpu-ubuntu-2404
.oxfmtrc.json+3
...@@ -9,8 +9,11 @@...@@ -9,8 +9,11 @@
9 "bun.lock",9 "bun.lock",
10 "*.tsbuildinfo",10 "*.tsbuildinfo",
11 "**/routeTree.gen.ts",11 "**/routeTree.gen.ts",
12 "apps/mobile/android/**",
13 "apps/mobile/ios/**",
12 "apps/web/public/mockServiceWorker.js",14 "apps/web/public/mockServiceWorker.js",
13 "apps/web/src/lib/vendor/qrcodegen.ts",15 "apps/web/src/lib/vendor/qrcodegen.ts",
16 "apps/mobile/uniwind-types.d.ts",
14 "*.icon/**"17 "*.icon/**"
15 ],18 ],
16 "sortPackageJson": {},19 "sortPackageJson": {},
.oxlintrc.json+5-1
...@@ -6,7 +6,10 @@...@@ -6,7 +6,10 @@
6 "node_modules",6 "node_modules",
7 "bun.lock",7 "bun.lock",
8 "*.tsbuildinfo",8 "*.tsbuildinfo",
9 "**/routeTree.gen.ts"9 "**/routeTree.gen.ts",
10 "apps/mobile/android/**",
11 "apps/mobile/ios/**",
12 "apps/mobile/uniwind-types.d.ts"
10 ],13 ],
11 "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"],14 "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"],
12 "jsPlugins": ["./oxlint-plugin-t3code/index.ts"],15 "jsPlugins": ["./oxlint-plugin-t3code/index.ts"],
...@@ -16,6 +19,7 @@...@@ -16,6 +19,7 @@
16 "perf": "warn"19 "perf": "warn"
17 },20 },
18 "rules": {21 "rules": {
22 "unicorn/no-array-sort": "off",
19 "react-in-jsx-scope": "off",23 "react-in-jsx-scope": "off",
20 "eslint/no-shadow": "off",24 "eslint/no-shadow": "off",
21 "eslint/no-await-in-loop": "off",25 "eslint/no-await-in-loop": "off",
AGENTS.md+1
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3## Task Completion Requirements3## Task Completion Requirements
44
5- All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed.5- All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed.
6 - If changing native mobile code, `bun lint:mobile` must also pass.
6- NEVER run `bun test`. Always use `bun run test` (runs Vitest).7- NEVER run `bun test`. Always use `bun run test` (runs Vitest).
78
8## Project Snapshot9## Project Snapshot
apps/mobile/.editorconfig created+12
...@@ -0,0 +1,12 @@
1root = false
2
3[*.{kt,kts}]
4indent_size = 2
5ktlint_code_style = android_studio
6ktlint_standard_blank-line-between-when-conditions = disabled
7ktlint_standard_class-signature = disabled
8ktlint_standard_function-signature = disabled
9ktlint_standard_import-ordering = disabled
10ktlint_standard_multiline-expression-wrapping = disabled
11ktlint_standard_trailing-comma-on-call-site = disabled
12ktlint_standard_when-entry-bracing = disabled
apps/mobile/.gitignore created+41
...@@ -0,0 +1,41 @@
1# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2
3# dependencies
4node_modules/
5
6# Expo
7.expo/
8dist/
9web-build/
10expo-env.d.ts
11
12# Native
13.kotlin/
14*.orig.*
15*.jks
16*.p8
17*.p12
18*.key
19*.mobileprovision
20
21# Metro
22.metro-health-check*
23
24# debug
25npm-debug.*
26yarn-debug.*
27yarn-error.*
28
29# macOS
30.DS_Store
31*.pem
32
33# local env files
34.env*.local
35
36# typescript
37*.tsbuildinfo
38
39# generated native folders
40/ios
41/android
apps/mobile/.swiftlint.yml created+58
...@@ -0,0 +1,58 @@
1included:
2 - ios/T3Code
3 - modules/t3-terminal/ios
4 - modules/t3-review-diff/ios
5
6excluded:
7 - ios/Pods
8 - ios/build
9 - modules/t3-terminal/Vendor
10
11reporter: xcode
12
13disabled_rules:
14 - file_length
15 - function_body_length
16 - identifier_name
17 - line_length
18 - lower_acl_than_parent
19 - modifier_order
20 - trailing_comma
21 - type_body_length
22
23opt_in_rules:
24 - array_init
25 - closure_end_indentation
26 - closure_spacing
27 - collection_alignment
28 - contains_over_filter_count
29 - contains_over_filter_is_empty
30 - contains_over_first_not_nil
31 - contains_over_range_nil_comparison
32 - empty_collection_literal
33 - empty_count
34 - empty_string
35 - enum_case_associated_values_count
36 - fallthrough
37 - fatal_error_message
38 - first_where
39 - flatmap_over_map_reduce
40 - force_unwrapping
41 - implicitly_unwrapped_optional
42 - last_where
43 - legacy_multiple
44 - legacy_random
45 - operator_usage_whitespace
46 - overridden_super_call
47 - prefer_self_type_over_type_of_self
48 - private_action
49 - private_outlet
50 - prohibited_super_call
51 - reduce_into
52 - redundant_nil_coalescing
53 - sorted_first_last
54 - static_operator
55 - toggle_bool
56 - unneeded_parentheses_in_closure_argument
57 - vertical_parameter_alignment_on_call
58 - yoda_condition
apps/mobile/Brewfile created+3
...@@ -0,0 +1,3 @@
1brew "swiftlint"
2brew "ktlint"
3brew "detekt"
apps/mobile/README.md created+81
...@@ -0,0 +1,81 @@
1# T3 Code Mobile
2
3> [!WARNING]
4> T3 Code Mobile is currently in development and is not distributed yet. If you want to try it out, you can build it from source.
5
6## Quickstart
7
8> [!NOTE]
9> Uses native modules so using Expo Go is not supported. You need to use the Expo Dev Client.
10
11This app has three variants:
12
13- `development`: Expo dev client, installable side-by-side as `T3 Code Dev`
14- `preview`: persistent internal preview build, installable side-by-side as `T3 Code Preview`
15- `production`: store/release build as `T3 Code`
16
17Run commands from `apps/mobile`.
18
19## Development
20
21Start Metro for the dev client:
22
23```bash
24bun run dev:client
25```
26
27Build and run the local iOS dev client:
28
29```bash
30bun run ios:dev
31```
32
33Build and run the local iOS preview app:
34
35```bash
36bun run ios:preview
37```
38
39Force the review diff highlighter engine:
40
41```bash
42EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE=javascript bun run ios:dev
43```
44
45`javascript` is the default and recommended setting for the review diff screen. Set `EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE=native` only when you explicitly want to test the native Shiki engine.
46
47Inspect the resolved Expo config for a variant:
48
49```bash
50bun run config:dev
51bun run config:preview
52```
53
54Run static checks for mobile native code:
55
56```bash
57node ../../scripts/mobile-native-static-check.ts
58```
59
60The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. Missing native tools are reported as warnings and skipped locally. CI installs the default toolset from `apps/mobile/Brewfile` before running the native checks.
61
62## EAS Builds
63
64Create a cloud dev-client build:
65
66```bash
67bun run eas:ios:dev
68```
69
70Create a persistent preview build:
71
72```bash
73bun run eas:ios:preview
74```
75
76Android equivalents:
77
78```bash
79bun run eas:android:dev
80bun run eas:android:preview
81```
apps/mobile/app.config.ts created+140
...@@ -0,0 +1,140 @@
1import type { ExpoConfig } from "expo/config";
2
3type AppVariant = "development" | "preview" | "production";
4
5const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT);
6
7const VARIANT_CONFIG: Record<
8 AppVariant,
9 {
10 readonly appName: string;
11 readonly scheme: string;
12 readonly iosIcon: string;
13 readonly iosBundleIdentifier: string;
14 readonly androidPackage: string;
15 }
16> = {
17 development: {
18 appName: "T3 Code Dev",
19 scheme: "t3code-dev",
20 iosIcon: "./assets/icon-composer-dev.icon",
21 iosBundleIdentifier: "com.t3tools.t3code.dev",
22 androidPackage: "com.t3tools.t3code.dev",
23 },
24 preview: {
25 appName: "T3 Code Preview",
26 scheme: "t3code-preview",
27 iosIcon: "./assets/icon-composer-prod.icon",
28 iosBundleIdentifier: "com.t3tools.t3code.preview",
29 androidPackage: "com.t3tools.t3code.preview",
30 },
31 production: {
32 appName: "T3 Code",
33 scheme: "t3code",
34 iosIcon: "./assets/icon-composer-prod.icon",
35 iosBundleIdentifier: "com.t3tools.t3code",
36 androidPackage: "com.t3tools.t3code",
37 },
38};
39
40function resolveAppVariant(value: string | undefined): AppVariant {
41 switch (value) {
42 case "development":
43 case "preview":
44 case "production":
45 return value;
46 default:
47 return "production";
48 }
49}
50
51const variant = VARIANT_CONFIG[APP_VARIANT];
52
53const config: ExpoConfig = {
54 name: variant.appName,
55 slug: "t3-code",
56 scheme: variant.scheme,
57 version: "0.1.0",
58 runtimeVersion: {
59 policy: "appVersion",
60 },
61 orientation: "portrait",
62 icon: "./assets/icon.png",
63 userInterfaceStyle: "automatic",
64 updates: {
65 enabled: true,
66 url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",
67 checkAutomatically: "ON_LOAD",
68 fallbackToCacheTimeout: 0,
69 },
70 ios: {
71 icon: variant.iosIcon,
72 supportsTablet: true,
73 bundleIdentifier: variant.iosBundleIdentifier,
74 infoPlist: {
75 NSAppTransportSecurity: {
76 NSAllowsArbitraryLoads: true,
77 },
78 NSLocalNetworkUsageDescription:
79 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
80 ITSAppUsesNonExemptEncryption: false,
81 },
82 },
83 android: {
84 icon: "./assets/icon.png",
85 package: variant.androidPackage,
86 adaptiveIcon: {
87 backgroundColor: "#E6F4FE",
88 foregroundImage: "./assets/android-icon-foreground.png",
89 backgroundImage: "./assets/android-icon-background.png",
90 monochromeImage: "./assets/android-icon-monochrome.png",
91 },
92 predictiveBackGestureEnabled: false,
93 },
94 web: {
95 favicon: "./assets/favicon.png",
96 },
97 plugins: [
98 [
99 "expo-camera",
100 {
101 cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.",
102 barcodeScannerEnabled: true,
103 },
104 ],
105 [
106 "expo-splash-screen",
107 {
108 image: "./assets/splash-icon.png",
109 resizeMode: "contain",
110 backgroundColor: "#ffffff",
111 imageWidth: 220,
112 dark: {
113 image: "./assets/splash-icon.png",
114 backgroundColor: "#0a0a0a",
115 },
116 },
117 ],
118 [
119 "expo-build-properties",
120 {
121 ios: {
122 deploymentTarget: "16.4",
123 },
124 },
125 ],
126 "expo-secure-store",
127 "expo-router",
128 "expo-font",
129 "./plugins/withAndroidCleartextTraffic.cjs",
130 ],
131 extra: {
132 appVariant: APP_VARIANT,
133 eas: {
134 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
135 },
136 },
137 owner: "pingdotgg",
138};
139
140export default config;
apps/mobile/assets.d.ts created+1
...@@ -0,0 +1 @@
1declare module "*.css";
apps/mobile/assets/android-icon-background.png created
Binary files /dev/null and b/apps/mobile/assets/android-icon-background.png differ
apps/mobile/assets/android-icon-foreground.png created
Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ
apps/mobile/assets/android-icon-monochrome.png created
Binary files /dev/null and b/apps/mobile/assets/android-icon-monochrome.png differ
apps/mobile/assets/favicon.png created
Binary files /dev/null and b/apps/mobile/assets/favicon.png differ
apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg created+3
...@@ -0,0 +1,3 @@
1<svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/>
3</svg>
apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg created
Binary files /dev/null and b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg differ
apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png created
Binary files /dev/null and b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png differ
apps/mobile/assets/icon-composer-dev.icon/icon.json created+45
...@@ -0,0 +1,45 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "hidden": false,
10 "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png",
11 "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr",
12 "position": {
13 "scale": 1.1,
14 "translation-in-points": [0, 0]
15 }
16 },
17 {
18 "image-name": "T3.svg",
19 "name": "T3",
20 "position": {
21 "scale": 10,
22 "translation-in-points": [0, 0]
23 }
24 },
25 {
26 "hidden": true,
27 "image-name": "Texturelabs_Paper_381XL.jpg",
28 "name": "Texturelabs_Paper_381XL"
29 }
30 ],
31 "shadow": {
32 "kind": "neutral",
33 "opacity": 0.5
34 },
35 "translucency": {
36 "enabled": true,
37 "value": 0.5
38 }
39 }
40 ],
41 "supported-platforms": {
42 "circles": ["watchOS"],
43 "squares": "shared"
44 }
45}
apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg created+3
...@@ -0,0 +1,3 @@
1<svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/>
3</svg>
apps/mobile/assets/icon-composer-prod.icon/icon.json created+31
...@@ -0,0 +1,31 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "image-name": "T3.svg",
10 "name": "T3",
11 "position": {
12 "scale": 10,
13 "translation-in-points": [0, 0]
14 }
15 }
16 ],
17 "shadow": {
18 "kind": "neutral",
19 "opacity": 0.5
20 },
21 "translucency": {
22 "enabled": true,
23 "value": 0.5
24 }
25 }
26 ],
27 "supported-platforms": {
28 "circles": ["watchOS"],
29 "squares": "shared"
30 }
31}
apps/mobile/assets/icon.png created
Binary files /dev/null and b/apps/mobile/assets/icon.png differ
apps/mobile/assets/splash-icon.png created
Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ
apps/mobile/babel.config.js created+6
...@@ -0,0 +1,6 @@
1module.exports = function (api) {
2 api.cache(true);
3 return {
4 presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]],
5 };
6};
apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz created
Binary files /dev/null and b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz differ
apps/mobile/detekt.yml created+19
...@@ -0,0 +1,19 @@
1config:
2 validation: true
3
4build:
5 maxIssues: 0
6
7complexity:
8 LongMethod:
9 active: true
10 threshold: 80
11 TooManyFunctions:
12 active: false
13
14style:
15 MagicNumber:
16 active: false
17 MaxLineLength:
18 active: true
19 maxLineLength: 120
apps/mobile/eas.json created+37
...@@ -0,0 +1,37 @@
1{
2 "cli": {
3 "version": ">= 18.4.0",
4 "appVersionSource": "remote"
5 },
6 "build": {
7 "development": {
8 "env": {
9 "APP_VARIANT": "development"
10 },
11 "channel": "development",
12 "developmentClient": true,
13 "distribution": "internal"
14 },
15 "preview": {
16 "env": {
17 "APP_VARIANT": "preview"
18 },
19 "channel": "preview",
20 "distribution": "internal"
21 },
22 "production": {
23 "env": {
24 "APP_VARIANT": "production"
25 },
26 "channel": "production",
27 "autoIncrement": true
28 }
29 },
30 "submit": {
31 "production": {
32 "ios": {
33 "ascAppId": "6761315631"
34 }
35 }
36 }
37}
apps/mobile/global.css created+202
...@@ -0,0 +1,202 @@
1@import "tailwindcss";
2@import "uniwind";
3
4/* ─── Theme tokens ──────────────────────────────────────────────────── */
5@layer theme {
6 :root {
7 @variant light {
8 /* Page backgrounds */
9 --color-screen: #f2f2f7;
10 --color-sheet: rgba(242, 242, 247, 0.98);
11
12 /* Card / surface */
13 --color-card: #ffffff;
14 --color-card-alt: #f5f5f5;
15 --color-card-translucent: rgba(255, 255, 255, 0.94);
16
17 /* Text */
18 --color-foreground: #262626;
19 --color-foreground-secondary: #525252;
20 --color-foreground-muted: #737373;
21 --color-foreground-tertiary: #a3a3a3;
22
23 /* Borders & separators */
24 --color-border: rgba(0, 0, 0, 0.08);
25 --color-border-subtle: rgba(0, 0, 0, 0.06);
26 --color-separator: rgba(0, 0, 0, 0.04);
27
28 /* Subtle backgrounds (badges, pills, overlays) */
29 --color-subtle: rgba(0, 0, 0, 0.04);
30 --color-subtle-strong: rgba(0, 0, 0, 0.08);
31
32 /* Primary action */
33 --color-primary: #262626;
34 --color-primary-foreground: #ffffff;
35 --color-primary-shadow: rgba(0, 0, 0, 0.18);
36
37 /* Secondary action */
38 --color-secondary: #ffffff;
39 --color-secondary-foreground: #262626;
40 --color-secondary-border: rgba(0, 0, 0, 0.08);
41
42 /* Danger */
43 --color-danger: #fef2f2;
44 --color-danger-border: rgba(239, 68, 68, 0.12);
45 --color-danger-foreground: #dc2626;
46
47 /* Inputs */
48 --color-input: #ffffff;
49 --color-input-border: rgba(0, 0, 0, 0.1);
50 --color-placeholder: #a3a3a3;
51
52 /* Icons */
53 --color-icon: #262626;
54 --color-icon-muted: #525252;
55 --color-icon-subtle: #a3a3a3;
56
57 /* Header / glass chrome */
58 --color-header: rgba(255, 255, 255, 0.97);
59 --color-header-border: rgba(0, 0, 0, 0.06);
60
61 /* StatusBar */
62 --color-status-bar: #f2f2f7;
63
64 /* Markdown */
65 --color-md-body: #111111;
66 --color-md-strong: #000000;
67 --color-md-link: #2563eb;
68 --color-md-blockquote-border: rgba(0, 0, 0, 0.08);
69 --color-md-blockquote-bg: rgba(0, 0, 0, 0.02);
70 --color-md-code-bg: rgba(0, 0, 0, 0.04);
71 --color-md-code-text: #262626;
72 --color-md-user-code-bg: rgba(255, 255, 255, 0.22);
73 --color-md-user-code-text: #ffffff;
74 --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
75 --color-md-user-fence-text: #ffffff;
76 --color-md-hr: rgba(0, 0, 0, 0.08);
77
78 /* iMessage-style user bubble */
79 --color-user-bubble: #007aff;
80 --color-user-bubble-foreground: #ffffff;
81 --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
82
83 /* Drawer / modal backdrop */
84 --color-backdrop: rgba(0, 0, 0, 0.22);
85 --color-drawer: rgba(255, 255, 255, 0.99);
86 --color-drawer-shadow: rgba(0, 0, 0, 0.12);
87
88 /* Misc */
89 --color-dot-separator: rgba(0, 0, 0, 0.2);
90 --color-wordmark: #262626;
91 --color-chevron: rgba(0, 0, 0, 0.2);
92 }
93
94 @variant dark {
95 /* Page backgrounds */
96 --color-screen: #0a0a0a;
97 --color-sheet: rgba(14, 14, 14, 0.98);
98
99 /* Card / surface */
100 --color-card: #171717;
101 --color-card-alt: #1c1c1c;
102 --color-card-translucent: rgba(17, 17, 17, 0.94);
103
104 /* Text */
105 --color-foreground: #f5f5f5;
106 --color-foreground-secondary: #a3a3a3;
107 --color-foreground-muted: #737373;
108 --color-foreground-tertiary: #525252;
109
110 /* Borders & separators */
111 --color-border: rgba(255, 255, 255, 0.06);
112 --color-border-subtle: rgba(255, 255, 255, 0.04);
113 --color-separator: rgba(255, 255, 255, 0.03);
114
115 /* Subtle backgrounds (badges, pills, overlays) */
116 --color-subtle: rgba(255, 255, 255, 0.04);
117 --color-subtle-strong: rgba(255, 255, 255, 0.08);
118
119 /* Primary action */
120 --color-primary: #f5f5f5;
121 --color-primary-foreground: #0a0a0a;
122 --color-primary-shadow: rgba(0, 0, 0, 0.22);
123
124 /* Secondary action */
125 --color-secondary: rgba(255, 255, 255, 0.04);
126 --color-secondary-foreground: #f5f5f5;
127 --color-secondary-border: rgba(255, 255, 255, 0.06);
128
129 /* Danger */
130 --color-danger: rgba(239, 68, 68, 0.14);
131 --color-danger-border: rgba(248, 113, 113, 0.18);
132 --color-danger-foreground: #fca5a5;
133
134 /* Inputs */
135 --color-input: #141414;
136 --color-input-border: rgba(255, 255, 255, 0.08);
137 --color-placeholder: #737373;
138
139 /* Icons */
140 --color-icon: #f5f5f5;
141 --color-icon-muted: #a3a3a3;
142 --color-icon-subtle: #737373;
143
144 /* Header / glass chrome */
145 --color-header: rgba(10, 10, 10, 0.97);
146 --color-header-border: rgba(255, 255, 255, 0.06);
147
148 /* StatusBar */
149 --color-status-bar: #0a0a0a;
150
151 /* Markdown */
152 --color-md-body: #e5e5e5;
153 --color-md-strong: #f5f5f5;
154 --color-md-link: #60a5fa;
155 --color-md-blockquote-border: rgba(255, 255, 255, 0.1);
156 --color-md-blockquote-bg: rgba(255, 255, 255, 0.03);
157 --color-md-code-bg: rgba(255, 255, 255, 0.06);
158 --color-md-code-text: #e5e5e5;
159 --color-md-user-code-bg: rgba(255, 255, 255, 0.18);
160 --color-md-user-code-text: #ffffff;
161 --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
162 --color-md-user-fence-text: #ffffff;
163 --color-md-hr: rgba(255, 255, 255, 0.08);
164
165 /* iMessage-style user bubble */
166 --color-user-bubble: #0a84ff;
167 --color-user-bubble-foreground: #ffffff;
168 --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
169
170 /* Drawer / modal backdrop */
171 --color-backdrop: rgba(0, 0, 0, 0.48);
172 --color-drawer: rgba(14, 14, 14, 0.99);
173 --color-drawer-shadow: rgba(0, 0, 0, 0.32);
174
175 /* Misc */
176 --color-dot-separator: rgba(255, 255, 255, 0.2);
177 --color-wordmark: #f5f5f5;
178 --color-chevron: rgba(255, 255, 255, 0.2);
179 }
180 }
181}
182
183/* ─── Font family ───────────────────────────────────────────────────── */
184@theme {
185 --font-sans: "DMSans_400Regular";
186 --font-medium: "DMSans_500Medium";
187 --font-bold: "DMSans_700Bold";
188}
189
190/* ─── Custom utilities ──────────────────────────────────────────────── */
191@utility font-t3-medium {
192 font-family: "DMSans_500Medium";
193}
194
195@utility font-t3-bold {
196 font-family: "DMSans_700Bold";
197}
198
199@utility font-t3-extrabold {
200 font-family: "DMSans_700Bold";
201 font-weight: 800;
202}
apps/mobile/index.ts created+2
...@@ -0,0 +1,2 @@
1import "react-native-gesture-handler";
2import "expo-router/entry";
apps/mobile/metro.config.js created+45
...@@ -0,0 +1,45 @@
1const fs = require("node:fs");
2const path = require("node:path");
3const { getDefaultConfig } = require("expo/metro-config");
4const { withUniwindConfig } = require("uniwind/metro");
5
6/** @type {import("expo/metro-config").MetroConfig} */
7const config = getDefaultConfig(__dirname);
8const workspaceRoot = path.resolve(__dirname, "../..");
9const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));
10const resolveShikiDependencyRoot = (packageName) => {
11 const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });
12 let currentDir = path.dirname(entryPath);
13
14 while (!fs.existsSync(path.join(currentDir, "package.json"))) {
15 const parentDir = path.dirname(currentDir);
16 if (parentDir === currentDir) {
17 throw new Error(`Could not resolve package root for ${packageName}`);
18 }
19 currentDir = parentDir;
20 }
21
22 return currentDir;
23};
24
25config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];
26config.resolver = {
27 ...config.resolver,
28 extraNodeModules: {
29 // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread
30 ...(config.resolver?.extraNodeModules ?? {}),
31 shiki: mobileShikiRoot,
32 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),
33 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),
34 "@shikijs/engine-oniguruma": resolveShikiDependencyRoot("@shikijs/engine-oniguruma"),
35 "@shikijs/langs": resolveShikiDependencyRoot("@shikijs/langs"),
36 "@shikijs/themes": resolveShikiDependencyRoot("@shikijs/themes"),
37 "@shikijs/types": resolveShikiDependencyRoot("@shikijs/types"),
38 "@shikijs/vscode-textmate": resolveShikiDependencyRoot("@shikijs/vscode-textmate"),
39 },
40};
41
42module.exports = withUniwindConfig(config, {
43 cssEntryFile: "./global.css",
44 polyfills: { rem: 14 },
45});
apps/mobile/modules/t3-review-diff/T3ReviewDiffNative.podspec created+19
...@@ -0,0 +1,19 @@
1require 'json'
2
3package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
5Pod::Spec.new do |s|
6 s.name = 'T3ReviewDiffNative'
7 s.version = package['version']
8 s.summary = 'Native review diff debug surface for T3 Code mobile.'
9 s.description = 'Native iOS review diff renderer used to prototype fast mobile review scrolling.'
10 s.homepage = 'https://t3tools.com'
11 s.license = { :type => 'UNLICENSED' }
12 s.author = { 'T3 Tools' => 'hello@t3tools.com' }
13 s.platforms = { :ios => '16.1' }
14 s.source = { :path => '.' }
15 s.source_files = 'ios/**/*.{h,m,mm,swift}'
16 s.frameworks = 'CoreGraphics', 'UIKit'
17 s.swift_version = '5.9'
18 s.dependency 'ExpoModulesCore'
19end
apps/mobile/modules/t3-review-diff/expo-module.config.json created+7
...@@ -0,0 +1,7 @@
1{
2 "platforms": ["apple"],
3 "apple": {
4 "modules": ["T3ReviewDiffModule"],
5 "podspecPath": "T3ReviewDiffNative.podspec"
6 }
7}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift created+63
...@@ -0,0 +1,63 @@
1import ExpoModulesCore
2
3public class T3ReviewDiffModule: Module {
4 public func definition() -> ModuleDefinition {
5 Name("T3ReviewDiffSurface")
6
7 View(T3ReviewDiffView.self) {
8 Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in
9 view.setRowsJson(rowsJson)
10 }
11
12 Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in
13 view.setTokensJson(tokensJson)
14 }
15
16 Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in
17 view.setTokensPatchJson(tokensPatchJson)
18 }
19
20 Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in
21 view.setTokensResetKey(tokensResetKey)
22 }
23
24 Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in
25 view.setCollapsedFileIdsJson(collapsedFileIdsJson)
26 }
27
28 Prop("viewedFileIdsJson") { (view: T3ReviewDiffView, viewedFileIdsJson: String) in
29 view.setViewedFileIdsJson(viewedFileIdsJson)
30 }
31
32 Prop("selectedRowIdsJson") { (view: T3ReviewDiffView, selectedRowIdsJson: String) in
33 view.setSelectedRowIdsJson(selectedRowIdsJson)
34 }
35
36 Prop("collapsedCommentIdsJson") { (view: T3ReviewDiffView, collapsedCommentIdsJson: String) in
37 view.setCollapsedCommentIdsJson(collapsedCommentIdsJson)
38 }
39
40 Prop("appearanceScheme") { (view: T3ReviewDiffView, appearanceScheme: String) in
41 view.setAppearanceScheme(appearanceScheme)
42 }
43
44 Prop("themeJson") { (view: T3ReviewDiffView, themeJson: String) in
45 view.setThemeJson(themeJson)
46 }
47
48 Prop("styleJson") { (view: T3ReviewDiffView, styleJson: String) in
49 view.setStyleJson(styleJson)
50 }
51
52 Prop("rowHeight") { (view: T3ReviewDiffView, rowHeight: Double) in
53 view.setRowHeight(CGFloat(rowHeight))
54 }
55
56 Prop("contentWidth") { (view: T3ReviewDiffView, contentWidth: Double) in
57 view.setContentWidth(CGFloat(contentWidth))
58 }
59
60 Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")
61 }
62 }
63}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift created+2311
...@@ -0,0 +1,2311 @@
1import ExpoModulesCore
2import UIKit
3
4private struct ReviewDiffNativeRow: Decodable {
5 let kind: String
6 let id: String
7 let fileId: String?
8 let filePath: String?
9 let previousPath: String?
10 let changeType: String?
11 let additions: Int?
12 let deletions: Int?
13 let text: String?
14 let content: String?
15 let change: String?
16 let oldLineNumber: Int?
17 let newLineNumber: Int?
18 let wordDiffRanges: [ReviewDiffNativeWordDiffRange]?
19 let commentText: String?
20 let commentRangeLabel: String?
21 let commentSectionTitle: String?
22}
23
24private struct ReviewDiffNativeWordDiffRange: Decodable {
25 let start: Int
26 let end: Int
27}
28
29private struct ReviewDiffNativeToken: Decodable {
30 let content: String
31 let color: String?
32 let fontStyle: Int?
33}
34
35private struct ReviewDiffNativeTokenPatch: Decodable {
36 let resetKey: String?
37 let chunkIndex: Int?
38 let tokensByRowId: [String: [ReviewDiffNativeToken]]?
39}
40
41private struct ReviewDiffNativeThemePayload: Decodable {
42 let background: String?
43 let text: String?
44 let mutedText: String?
45 let headerBackground: String?
46 let border: String?
47 let hunkBackground: String?
48 let hunkText: String?
49 let addBackground: String?
50 let deleteBackground: String?
51 let addBar: String?
52 let deleteBar: String?
53 let addText: String?
54 let deleteText: String?
55}
56
57private struct ReviewDiffNativeTheme {
58 let background: UIColor
59 let text: UIColor
60 let mutedText: UIColor
61 let headerBackground: UIColor
62 let border: UIColor
63 let hunkBackground: UIColor
64 let hunkText: UIColor
65 let addBackground: UIColor
66 let deleteBackground: UIColor
67 let addBar: UIColor
68 let deleteBar: UIColor
69 let addText: UIColor
70 let deleteText: UIColor
71
72 static func resolve(_ scheme: String) -> ReviewDiffNativeTheme {
73 resolve(scheme, payload: nil)
74 }
75
76 static func resolve(
77 _ scheme: String,
78 payload: ReviewDiffNativeThemePayload?
79 ) -> ReviewDiffNativeTheme {
80 let fallback = fallback(scheme)
81 guard let payload else {
82 return fallback
83 }
84
85 return ReviewDiffNativeTheme(
86 background: UIColor(reviewDiffHex: payload.background) ?? fallback.background,
87 text: UIColor(reviewDiffHex: payload.text) ?? fallback.text,
88 mutedText: UIColor(reviewDiffHex: payload.mutedText) ?? fallback.mutedText,
89 headerBackground: UIColor(reviewDiffHex: payload.headerBackground) ?? fallback.headerBackground,
90 border: UIColor(reviewDiffHex: payload.border) ?? fallback.border,
91 hunkBackground: UIColor(reviewDiffHex: payload.hunkBackground) ?? fallback.hunkBackground,
92 hunkText: UIColor(reviewDiffHex: payload.hunkText) ?? fallback.hunkText,
93 addBackground: UIColor(reviewDiffHex: payload.addBackground) ?? fallback.addBackground,
94 deleteBackground: UIColor(reviewDiffHex: payload.deleteBackground) ?? fallback.deleteBackground,
95 addBar: UIColor(reviewDiffHex: payload.addBar) ?? fallback.addBar,
96 deleteBar: UIColor(reviewDiffHex: payload.deleteBar) ?? fallback.deleteBar,
97 addText: UIColor(reviewDiffHex: payload.addText) ?? fallback.addText,
98 deleteText: UIColor(reviewDiffHex: payload.deleteText) ?? fallback.deleteText
99 )
100 }
101
102 private static func fallback(_ scheme: String) -> ReviewDiffNativeTheme {
103 if scheme == "dark" {
104 return ReviewDiffNativeTheme(
105 background: UIColor(red: 0.07, green: 0.07, blue: 0.07, alpha: 1),
106 text: UIColor(red: 0.90, green: 0.90, blue: 0.90, alpha: 1),
107 mutedText: UIColor(red: 0.52, green: 0.52, blue: 0.52, alpha: 1),
108 headerBackground: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1),
109 border: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1),
110 hunkBackground: UIColor(red: 0.03, green: 0.14, blue: 0.18, alpha: 1),
111 hunkText: UIColor(red: 0.41, green: 0.82, blue: 1.00, alpha: 1),
112 addBackground: UIColor(red: 0.02, green: 0.16, blue: 0.10, alpha: 1),
113 deleteBackground: UIColor(red: 0.18, green: 0.05, blue: 0.08, alpha: 1),
114 addBar: UIColor(red: 0.02, green: 0.82, blue: 0.54, alpha: 1),
115 deleteBar: UIColor(red: 1.00, green: 0.36, blue: 0.50, alpha: 1),
116 addText: UIColor(red: 0.10, green: 0.84, blue: 0.56, alpha: 1),
117 deleteText: UIColor(red: 1.00, green: 0.38, blue: 0.52, alpha: 1)
118 )
119 }
120
121 return ReviewDiffNativeTheme(
122 background: UIColor.white,
123 text: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1),
124 mutedText: UIColor(red: 0.47, green: 0.47, blue: 0.47, alpha: 1),
125 headerBackground: UIColor.white,
126 border: UIColor(red: 0.88, green: 0.88, blue: 0.90, alpha: 1),
127 hunkBackground: UIColor(red: 0.83, green: 0.92, blue: 0.98, alpha: 1),
128 hunkText: UIColor(red: 0.00, green: 0.45, blue: 0.74, alpha: 1),
129 addBackground: UIColor(red: 0.85, green: 0.94, blue: 0.92, alpha: 1),
130 deleteBackground: UIColor(red: 0.96, green: 0.85, blue: 0.90, alpha: 1),
131 addBar: UIColor(red: 0.02, green: 0.80, blue: 0.52, alpha: 1),
132 deleteBar: UIColor(red: 1.00, green: 0.34, blue: 0.48, alpha: 1),
133 addText: UIColor(red: 0.00, green: 0.56, blue: 0.34, alpha: 1),
134 deleteText: UIColor(red: 0.90, green: 0.10, blue: 0.24, alpha: 1)
135 )
136 }
137}
138
139private struct ReviewDiffNativeStylePayload: Decodable {
140 let rowHeight: Double?
141 let contentWidth: Double?
142 let changeBarWidth: Double?
143 let gutterWidth: Double?
144 let codePadding: Double?
145 let textVerticalInset: Double?
146 let fileHeaderHeight: Double?
147 let fileHeaderHorizontalMargin: Double?
148 let fileHeaderVerticalMargin: Double?
149 let fileHeaderCornerRadius: Double?
150 let fileHeaderHorizontalPadding: Double?
151 let fileHeaderPathRightPadding: Double?
152 let fileHeaderCountColumnWidth: Double?
153 let fileHeaderCountGap: Double?
154 let codeFontSize: Double?
155 let codeFontWeight: String?
156 let lineNumberFontSize: Double?
157 let lineNumberFontWeight: String?
158 let hunkFontSize: Double?
159 let hunkFontWeight: String?
160 let fileHeaderFontSize: Double?
161 let fileHeaderFontWeight: String?
162 let fileHeaderMetaFontSize: Double?
163 let fileHeaderMetaFontWeight: String?
164 let fileHeaderSubtextFontSize: Double?
165 let fileHeaderSubtextFontWeight: String?
166 let fileHeaderStatusFontSize: Double?
167 let fileHeaderStatusFontWeight: String?
168 let emptyStateFontSize: Double?
169 let emptyStateFontWeight: String?
170}
171
172private struct ReviewDiffNativeStyle {
173 let rowHeight: CGFloat
174 let contentWidth: CGFloat
175 let changeBarWidth: CGFloat
176 let gutterWidth: CGFloat
177 let codePadding: CGFloat
178 let textVerticalInset: CGFloat
179 let fileHeaderHeight: CGFloat
180 let fileHeaderHorizontalMargin: CGFloat
181 let fileHeaderVerticalMargin: CGFloat
182 let fileHeaderCornerRadius: CGFloat
183 let fileHeaderHorizontalPadding: CGFloat
184 let fileHeaderPathRightPadding: CGFloat
185 let fileHeaderCountColumnWidth: CGFloat
186 let fileHeaderCountGap: CGFloat
187 let codeFontSize: CGFloat
188 let codeFontWeight: UIFont.Weight
189 let lineNumberFontSize: CGFloat
190 let lineNumberFontWeight: UIFont.Weight
191 let hunkFontSize: CGFloat
192 let hunkFontWeight: UIFont.Weight
193 let fileHeaderFontSize: CGFloat
194 let fileHeaderFontWeight: UIFont.Weight
195 let fileHeaderMetaFontSize: CGFloat
196 let fileHeaderMetaFontWeight: UIFont.Weight
197 let fileHeaderSubtextFontSize: CGFloat
198 let fileHeaderSubtextFontWeight: UIFont.Weight
199 let fileHeaderStatusFontSize: CGFloat
200 let fileHeaderStatusFontWeight: UIFont.Weight
201 let emptyStateFontSize: CGFloat
202 let emptyStateFontWeight: UIFont.Weight
203
204 static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle {
205 ReviewDiffNativeStyle(
206 rowHeight: metric(payload?.rowHeight, fallback: 24),
207 contentWidth: metric(payload?.contentWidth, fallback: 2800),
208 changeBarWidth: metric(payload?.changeBarWidth, fallback: 4),
209 gutterWidth: metric(payload?.gutterWidth, fallback: 50),
210 codePadding: metric(payload?.codePadding, fallback: 8),
211 textVerticalInset: metric(payload?.textVerticalInset, fallback: 3),
212 fileHeaderHeight: metric(payload?.fileHeaderHeight, fallback: 54),
213 fileHeaderHorizontalMargin: metric(payload?.fileHeaderHorizontalMargin, fallback: 8),
214 fileHeaderVerticalMargin: metric(payload?.fileHeaderVerticalMargin, fallback: 6),
215 fileHeaderCornerRadius: metric(payload?.fileHeaderCornerRadius, fallback: 10),
216 fileHeaderHorizontalPadding: metric(payload?.fileHeaderHorizontalPadding, fallback: 12),
217 fileHeaderPathRightPadding: metric(payload?.fileHeaderPathRightPadding, fallback: 128),
218 fileHeaderCountColumnWidth: metric(payload?.fileHeaderCountColumnWidth, fallback: 42),
219 fileHeaderCountGap: metric(payload?.fileHeaderCountGap, fallback: 6),
220 codeFontSize: metric(payload?.codeFontSize, fallback: 12),
221 codeFontWeight: fontWeight(payload?.codeFontWeight, fallback: .bold),
222 lineNumberFontSize: metric(payload?.lineNumberFontSize, fallback: 11),
223 lineNumberFontWeight: fontWeight(payload?.lineNumberFontWeight, fallback: .bold),
224 hunkFontSize: metric(payload?.hunkFontSize, fallback: 12),
225 hunkFontWeight: fontWeight(payload?.hunkFontWeight, fallback: .bold),
226 fileHeaderFontSize: metric(payload?.fileHeaderFontSize, fallback: 13),
227 fileHeaderFontWeight: fontWeight(payload?.fileHeaderFontWeight, fallback: .bold),
228 fileHeaderMetaFontSize: metric(payload?.fileHeaderMetaFontSize, fallback: 12),
229 fileHeaderMetaFontWeight: fontWeight(payload?.fileHeaderMetaFontWeight, fallback: .bold),
230 fileHeaderSubtextFontSize: metric(payload?.fileHeaderSubtextFontSize, fallback: 11),
231 fileHeaderSubtextFontWeight: fontWeight(payload?.fileHeaderSubtextFontWeight, fallback: .medium),
232 fileHeaderStatusFontSize: metric(payload?.fileHeaderStatusFontSize, fallback: 10),
233 fileHeaderStatusFontWeight: fontWeight(payload?.fileHeaderStatusFontWeight, fallback: .semibold),
234 emptyStateFontSize: metric(payload?.emptyStateFontSize, fallback: 13),
235 emptyStateFontWeight: fontWeight(payload?.emptyStateFontWeight, fallback: .semibold)
236 )
237 }
238
239 private static func metric(_ value: Double?, fallback: CGFloat) -> CGFloat {
240 guard let value, value.isFinite, value > 0 else {
241 return fallback
242 }
243 return CGFloat(value)
244 }
245
246 private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight {
247 switch value?.lowercased() {
248 case "ultralight", "ultra-light":
249 return .ultraLight
250 case "thin":
251 return .thin
252 case "light":
253 return .light
254 case "regular":
255 return .regular
256 case "medium":
257 return .medium
258 case "semibold", "semi-bold":
259 return .semibold
260 case "bold":
261 return .bold
262 case "heavy":
263 return .heavy
264 case "black":
265 return .black
266 default:
267 return fallback
268 }
269 }
270
271 func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle {
272 ReviewDiffNativeStyle(
273 rowHeight: rowHeight ?? self.rowHeight,
274 contentWidth: contentWidth ?? self.contentWidth,
275 changeBarWidth: changeBarWidth,
276 gutterWidth: gutterWidth,
277 codePadding: codePadding,
278 textVerticalInset: textVerticalInset,
279 fileHeaderHeight: fileHeaderHeight,
280 fileHeaderHorizontalMargin: fileHeaderHorizontalMargin,
281 fileHeaderVerticalMargin: fileHeaderVerticalMargin,
282 fileHeaderCornerRadius: fileHeaderCornerRadius,
283 fileHeaderHorizontalPadding: fileHeaderHorizontalPadding,
284 fileHeaderPathRightPadding: fileHeaderPathRightPadding,
285 fileHeaderCountColumnWidth: fileHeaderCountColumnWidth,
286 fileHeaderCountGap: fileHeaderCountGap,
287 codeFontSize: codeFontSize,
288 codeFontWeight: codeFontWeight,
289 lineNumberFontSize: lineNumberFontSize,
290 lineNumberFontWeight: lineNumberFontWeight,
291 hunkFontSize: hunkFontSize,
292 hunkFontWeight: hunkFontWeight,
293 fileHeaderFontSize: fileHeaderFontSize,
294 fileHeaderFontWeight: fileHeaderFontWeight,
295 fileHeaderMetaFontSize: fileHeaderMetaFontSize,
296 fileHeaderMetaFontWeight: fileHeaderMetaFontWeight,
297 fileHeaderSubtextFontSize: fileHeaderSubtextFontSize,
298 fileHeaderSubtextFontWeight: fileHeaderSubtextFontWeight,
299 fileHeaderStatusFontSize: fileHeaderStatusFontSize,
300 fileHeaderStatusFontWeight: fileHeaderStatusFontWeight,
301 emptyStateFontSize: emptyStateFontSize,
302 emptyStateFontWeight: emptyStateFontWeight
303 )
304 }
305}
306
307public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
308 private let scrollView = UIScrollView()
309 private let contentView = ReviewDiffContentView()
310 private var rows: [ReviewDiffNativeRow] = []
311 private var appearanceScheme: String = "light"
312 private var themePayload: ReviewDiffNativeThemePayload?
313 private var stylePayload: ReviewDiffNativeStylePayload?
314 private var rowHeightOverride: CGFloat?
315 private var contentWidthOverride: CGFloat?
316 private var lastMetricsDebugKey = ""
317 private var lastVisibleRangeDebugKey = ""
318 private var tokensResetKey = ""
319
320 let onDebug = EventDispatcher()
321 let onToggleFile = EventDispatcher()
322 let onToggleViewedFile = EventDispatcher()
323 let onPressLine = EventDispatcher()
324 let onToggleComment = EventDispatcher()
325
326 public required init(appContext: AppContext? = nil) {
327 super.init(appContext: appContext)
328
329 clipsToBounds = true
330 backgroundColor = contentView.theme.background
331 scrollView.contentInsetAdjustmentBehavior = .never
332 scrollView.delegate = self
333 scrollView.clipsToBounds = true
334 scrollView.alwaysBounceVertical = true
335 scrollView.alwaysBounceHorizontal = false
336 scrollView.showsVerticalScrollIndicator = true
337 scrollView.showsHorizontalScrollIndicator = false
338 scrollView.backgroundColor = contentView.theme.background
339 addSubview(scrollView)
340
341 contentView.backgroundColor = contentView.theme.background
342 contentView.onToggleFile = { [weak self] fileId in
343 self?.onToggleFile(["fileId": fileId])
344 }
345 contentView.onToggleViewedFile = { [weak self] fileId in
346 self?.onToggleViewedFile(["fileId": fileId])
347 }
348 contentView.onPressLine = { [weak self] payload in
349 self?.onPressLine(payload)
350 }
351 contentView.onToggleComment = { [weak self] commentId in
352 self?.onToggleComment(["commentId": commentId])
353 }
354 contentView.onDrawMetrics = { [weak self] metrics in
355 self?.emitDebug("draw-metrics", metrics)
356 }
357 scrollView.addSubview(contentView)
358 }
359
360 public override func layoutSubviews() {
361 super.layoutSubviews()
362 scrollView.frame = bounds
363 updateContentMetrics()
364 }
365
366 public func scrollViewDidScroll(_ scrollView: UIScrollView) {
367 if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating {
368 contentView.isVerticalScrollActive = true
369 }
370 updateViewportFrame()
371 }
372
373 public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
374 guard !decelerate else {
375 return
376 }
377
378 finishVerticalScroll()
379 }
380
381 public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
382 finishVerticalScroll()
383 }
384
385 public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
386 finishVerticalScroll()
387 }
388
389 func setRowsJson(_ rowsJson: String) {
390 guard let data = rowsJson.data(using: .utf8) else {
391 return
392 }
393
394 do {
395 rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
396 contentView.rows = rows
397 emitDebug("rows-decoded", [
398 "rows": rows.count,
399 "firstKind": rows.first?.kind ?? "none",
400 ])
401 updateContentMetrics()
402 } catch {
403 rows = []
404 contentView.rows = []
405 updateContentMetrics()
406 emitDebug("rows-decode-failed", [
407 "error": error.localizedDescription,
408 ])
409 }
410 }
411
412 func setTokensJson(_ tokensJson: String) {
413 guard let data = tokensJson.data(using: .utf8) else {
414 return
415 }
416
417 do {
418 contentView.tokensByRowId = try JSONDecoder().decode(
419 [String: [ReviewDiffNativeToken]].self,
420 from: data
421 )
422 } catch {
423 contentView.tokensByRowId = [:]
424 emitDebug("tokens-decode-failed", [
425 "error": error.localizedDescription,
426 ])
427 }
428 }
429
430 func setTokensPatchJson(_ tokensPatchJson: String) {
431 guard let data = tokensPatchJson.data(using: .utf8) else {
432 return
433 }
434
435 do {
436 let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
437 if let resetKey = patch.resetKey, resetKey != tokensResetKey {
438 tokensResetKey = resetKey
439 contentView.tokensByRowId = [:]
440 }
441
442 let tokensByRowId = patch.tokensByRowId ?? [:]
443 if tokensByRowId.isEmpty {
444 return
445 }
446
447 contentView.mergeTokensByRowId(tokensByRowId)
448 if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
449 emitDebug("tokens-patch-decoded", [
450 "chunkIndex": chunkIndex,
451 "rows": tokensByRowId.count,
452 "totalRows": contentView.tokensByRowId.count,
453 ])
454 }
455 } catch {
456 emitDebug("tokens-patch-decode-failed", [
457 "error": error.localizedDescription,
458 ])
459 }
460 }
461
462 func setTokensResetKey(_ tokensResetKey: String) {
463 guard tokensResetKey != self.tokensResetKey else {
464 return
465 }
466
467 self.tokensResetKey = tokensResetKey
468 contentView.tokensByRowId = [:]
469 emitDebug("tokens-reset", [
470 "resetKey": tokensResetKey,
471 ])
472 }
473
474 func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {
475 let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)
476 let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)
477 let scrollAnchor: ReviewDiffScrollAnchor?
478 if changedFileIds.count == 1, let changedFileId = changedFileIds.first {
479 scrollAnchor = contentView.scrollAnchor(forFileId: changedFileId)
480 } else {
481 scrollAnchor = nil
482 }
483
484 contentView.collapsedFileIds = nextCollapsedFileIds
485 updateContentMetrics()
486
487 if let scrollAnchor,
488 let headerOffset = contentView.fileHeaderOffset(forFileId: scrollAnchor.fileId) {
489 let targetOffset = headerOffset - scrollAnchor.screenY
490 let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
491 let clampedOffset = min(max(targetOffset, 0), maxOffset)
492 scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: false)
493 updateViewportFrame()
494 }
495 }
496
497 func setViewedFileIdsJson(_ viewedFileIdsJson: String) {
498 contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson)
499 }
500
501 func setSelectedRowIdsJson(_ selectedRowIdsJson: String) {
502 contentView.selectedRowIds = decodeFileIdSet(selectedRowIdsJson)
503 }
504
505 func setCollapsedCommentIdsJson(_ collapsedCommentIdsJson: String) {
506 contentView.collapsedCommentIds = decodeFileIdSet(collapsedCommentIdsJson)
507 updateContentMetrics()
508 }
509
510 private func decodeFileIdSet(_ json: String) -> Set<String> {
511 guard let data = json.data(using: .utf8) else {
512 return []
513 }
514
515 do {
516 return Set(try JSONDecoder().decode([String].self, from: data))
517 } catch {
518 emitDebug("file-id-set-decode-failed", [
519 "error": error.localizedDescription,
520 ])
521 return []
522 }
523 }
524
525 func setAppearanceScheme(_ appearanceScheme: String) {
526 self.appearanceScheme = appearanceScheme
527 applyTheme()
528 }
529
530 func setThemeJson(_ themeJson: String) {
531 guard let data = themeJson.data(using: .utf8) else {
532 themePayload = nil
533 applyTheme()
534 return
535 }
536
537 do {
538 themePayload = try JSONDecoder().decode(ReviewDiffNativeThemePayload.self, from: data)
539 } catch {
540 themePayload = nil
541 emitDebug("theme-decode-failed", [
542 "error": error.localizedDescription,
543 ])
544 }
545
546 applyTheme()
547 }
548
549 private func updateContentMetrics() {
550 let style = contentView.style
551 let height = max(bounds.height, contentView.contentHeight)
552 let width = bounds.width
553 scrollView.contentSize = CGSize(width: bounds.width, height: height)
554 contentView.frame = CGRect(
555 x: 0,
556 y: scrollView.contentOffset.y,
557 width: max(width, 1),
558 height: max(bounds.height, 1)
559 )
560 contentView.viewportWidth = bounds.width
561 contentView.verticalOffset = scrollView.contentOffset.y
562 contentView.invalidateVisibleViewport()
563 contentView.setNeedsDisplay()
564
565 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
566 if debugKey != lastMetricsDebugKey {
567 lastMetricsDebugKey = debugKey
568 emitDebug("metrics", [
569 "rows": rows.count,
570 "boundsWidth": Double(bounds.width),
571 "boundsHeight": Double(bounds.height),
572 "contentHeight": Double(height),
573 "contentWidth": Double(style.contentWidth),
574 "fileHeaderHeight": Double(style.fileHeaderHeight),
575 "rowHeight": Double(style.rowHeight),
576 ])
577 }
578 }
579
580 private func emitDebug(_ message: String, _ details: [String: Any]) {
581 var payload = details
582 payload["message"] = message
583 onDebug(payload)
584 }
585
586 private func finishVerticalScroll() {
587 contentView.isVerticalScrollActive = false
588 updateViewportFrame()
589 emitVisibleRange(reason: "scroll-end")
590 }
591
592 private func emitVisibleRange(reason: String) {
593 guard let range = contentView.currentVisibleRowRange() else {
594 return
595 }
596
597 let debugKey = "\(range.firstRowIndex):\(range.lastRowIndex):\(Int(scrollView.bounds.height))"
598 guard debugKey != lastVisibleRangeDebugKey else {
599 return
600 }
601
602 lastVisibleRangeDebugKey = debugKey
603 emitDebug("visible-range", [
604 "reason": reason,
605 "firstRowIndex": range.firstRowIndex,
606 "lastRowIndex": range.lastRowIndex,
607 "totalRows": rows.count,
608 ])
609 }
610
611 private func applyTheme() {
612 contentView.theme = ReviewDiffNativeTheme.resolve(appearanceScheme, payload: themePayload)
613 backgroundColor = contentView.theme.background
614 scrollView.backgroundColor = contentView.theme.background
615 contentView.backgroundColor = contentView.theme.background
616 contentView.invalidateVisibleViewport()
617 }
618
619 func setStyleJson(_ styleJson: String) {
620 guard let data = styleJson.data(using: .utf8) else {
621 stylePayload = nil
622 applyStyle()
623 return
624 }
625
626 do {
627 stylePayload = try JSONDecoder().decode(ReviewDiffNativeStylePayload.self, from: data)
628 } catch {
629 stylePayload = nil
630 emitDebug("style-decode-failed", [
631 "error": error.localizedDescription,
632 ])
633 }
634
635 applyStyle()
636 }
637
638 func setRowHeight(_ rowHeight: CGFloat) {
639 rowHeightOverride = rowHeight.isFinite && rowHeight > 0 ? rowHeight : nil
640 applyStyle()
641 }
642
643 func setContentWidth(_ contentWidth: CGFloat) {
644 contentWidthOverride = contentWidth.isFinite && contentWidth > 0 ? contentWidth : nil
645 applyStyle()
646 }
647
648 private func applyStyle() {
649 contentView.style = ReviewDiffNativeStyle
650 .resolve(stylePayload)
651 .applyingOverrides(rowHeight: rowHeightOverride, contentWidth: contentWidthOverride)
652 updateContentMetrics()
653 }
654
655 private func updateViewportFrame() {
656 contentView.frame = CGRect(
657 x: 0,
658 y: scrollView.contentOffset.y,
659 width: max(bounds.width, 1),
660 height: max(bounds.height, 1)
661 )
662 contentView.verticalOffset = scrollView.contentOffset.y
663 contentView.invalidateVisibleViewport()
664 }
665}
666
667private enum ReviewDiffHorizontalPanKind {
668 case code
669 case fileHeaderPath
670}
671
672private struct ReviewDiffFileHeaderPathLayout {
673 let displayPath: String
674 let rect: CGRect
675}
676
677private struct ReviewDiffFileHeaderInteractiveRects {
678 let chevron: CGRect
679 let icon: CGRect
680 let checkbox: CGRect
681}
682
683private struct ReviewDiffStickyFileHeaderIndex {
684 let position: Int
685 let rowIndex: Int
686}
687
688private struct ReviewDiffStickyFileHeaderTarget {
689 let rowIndex: Int
690 let row: ReviewDiffNativeRow
691 let rect: CGRect
692}
693
694private struct ReviewDiffScrollAnchor {
695 let fileId: String
696 let screenY: CGFloat
697}
698
699private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
700 var rows: [ReviewDiffNativeRow] = [] {
701 didSet {
702 stopHorizontalDeceleration()
703 horizontalOffsetsByFileId.removeAll()
704 headerPathOffsetsByFileId.removeAll()
705 activePanFileId = nil
706 activePanKind = nil
707 tokenAttributedStringsByRowId.removeAll()
708 rebuildRowLayout()
709 setNeedsDisplayForVisibleBounds()
710 }
711 }
712 var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] {
713 didSet {
714 tokenAttributedStringsByRowId.removeAll()
715 clampHorizontalOffsets()
716 setNeedsDisplayForVisibleBounds()
717 }
718 }
719
720 func mergeTokensByRowId(_ tokensPatch: [String: [ReviewDiffNativeToken]]) {
721 tokensPatch.forEach { rowId, tokens in
722 tokensByRowId[rowId] = tokens
723 tokenAttributedStringsByRowId.removeValue(forKey: rowId)
724 }
725 clampHorizontalOffsets()
726 setNeedsDisplayForVisibleBounds()
727 }
728
729 var collapsedFileIds: Set<String> = [] {
730 didSet {
731 rebuildRowLayout()
732 clampHorizontalOffsets()
733 setNeedsDisplayForVisibleBounds()
734 }
735 }
736 var viewedFileIds: Set<String> = [] {
737 didSet {
738 setNeedsDisplayForVisibleBounds()
739 }
740 }
741 var selectedRowIds: Set<String> = [] {
742 didSet {
743 setNeedsDisplayForVisibleBounds()
744 }
745 }
746 var collapsedCommentIds: Set<String> = [] {
747 didSet {
748 rebuildRowLayout()
749 setNeedsDisplayForVisibleBounds()
750 }
751 }
752 var style = ReviewDiffNativeStyle.resolve(nil) {
753 didSet {
754 tokenAttributedStringsByRowId.removeAll()
755 rebuildRowLayout()
756 clampHorizontalOffsets()
757 setNeedsDisplayForVisibleBounds()
758 }
759 }
760 var viewportWidth: CGFloat = 0 {
761 didSet {
762 clampHorizontalOffsets()
763 setNeedsDisplayForVisibleBounds()
764 }
765 }
766 var verticalOffset: CGFloat = 0
767 var theme = ReviewDiffNativeTheme.resolve("light") {
768 didSet {
769 tokenColorsByHex.removeAll()
770 tokenAttributedStringsByRowId.removeAll()
771 setNeedsDisplayForVisibleBounds()
772 }
773 }
774 private(set) var contentHeight: CGFloat = 0
775
776 private var rowOffsets: [CGFloat] = []
777 private var fileHeaderRowIndices: [Int] = []
778 private var contentWidthsByFileId: [String: CGFloat] = [:]
779 private var tokenColorsByHex: [String: UIColor] = [:]
780 private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:]
781 private var codeCharacterWidth: CGFloat = 8
782 private var panStartHorizontalOffset: CGFloat = 0
783 private var activePanFileId: String?
784 private var activePanKind: ReviewDiffHorizontalPanKind?
785 private var horizontalOffsetsByFileId: [String: CGFloat] = [:]
786 private var headerPathOffsetsByFileId: [String: CGFloat] = [:]
787 private var decelerationDisplayLink: CADisplayLink?
788 private var deceleratingFileId: String?
789 private var horizontalVelocity: CGFloat = 0
790 private var lastDecelerationTimestamp: CFTimeInterval = 0
791 private var lastDrawMetricsTimestamp: CFTimeInterval = 0
792 var isVerticalScrollActive = false
793 var onToggleFile: ((String) -> Void)?
794 var onToggleViewedFile: ((String) -> Void)?
795 var onPressLine: (([String: Any]) -> Void)?
796 var onToggleComment: ((String) -> Void)?
797 var onDrawMetrics: (([String: Any]) -> Void)?
798
799 private var stickyWidth: CGFloat {
800 style.changeBarWidth + style.gutterWidth
801 }
802
803 private var codeStartX: CGFloat {
804 stickyWidth + style.codePadding
805 }
806
807 private func height(for row: ReviewDiffNativeRow) -> CGFloat {
808 if row.kind == "file" {
809 return style.fileHeaderHeight
810 }
811 if collapsedFileIds.contains(resolvedFileId(for: row)) {
812 return 0
813 }
814 if row.kind == "notice" {
815 return max(style.rowHeight * 2, 44)
816 }
817 if row.kind == "comment" {
818 return collapsedCommentIds.contains(row.id) ? 44 : 124
819 }
820 return style.rowHeight
821 }
822
823 private func rebuildRowLayout() {
824 var nextOffsets: [CGFloat] = []
825 var nextFileHeaderRowIndices: [Int] = []
826 nextOffsets.reserveCapacity(rows.count)
827 var maxColumnCountsByFileId: [String: Int] = [:]
828 var offset: CGFloat = 0
829
830 for (index, row) in rows.enumerated() {
831 nextOffsets.append(offset)
832 if row.kind == "file" {
833 nextFileHeaderRowIndices.append(index)
834 }
835 offset += height(for: row)
836
837 let fileId = resolvedFileId(for: row)
838 switch row.kind {
839 case "line":
840 maxColumnCountsByFileId[fileId] = max(
841 maxColumnCountsByFileId[fileId] ?? 0,
842 row.content?.count ?? 0
843 )
844 case "hunk":
845 maxColumnCountsByFileId[fileId] = max(
846 maxColumnCountsByFileId[fileId] ?? 0,
847 row.text?.count ?? 0
848 )
849 default:
850 continue
851 }
852 }
853
854 let characterWidth = monospaceCharacterWidth(font: codeFont)
855 codeCharacterWidth = characterWidth
856 contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in
857 let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2
858 return max(0, min(style.contentWidth, measuredWidth))
859 }
860 rowOffsets = nextOffsets
861 fileHeaderRowIndices = nextFileHeaderRowIndices
862 contentHeight = offset
863 }
864
865 private var codeFont: UIFont {
866 UIFont.monospacedSystemFont(ofSize: style.codeFontSize, weight: style.codeFontWeight)
867 }
868
869 private var lineNumberFont: UIFont {
870 UIFont.monospacedSystemFont(ofSize: style.lineNumberFontSize, weight: style.lineNumberFontWeight)
871 }
872
873 private var hunkFont: UIFont {
874 UIFont.monospacedSystemFont(ofSize: style.hunkFontSize, weight: style.hunkFontWeight)
875 }
876
877 private var fileHeaderFont: UIFont {
878 UIFont.systemFont(ofSize: style.fileHeaderFontSize, weight: style.fileHeaderFontWeight)
879 }
880
881 private var fileHeaderMetaFont: UIFont {
882 UIFont.systemFont(ofSize: style.fileHeaderMetaFontSize, weight: style.fileHeaderMetaFontWeight)
883 }
884
885 private var fileHeaderSubtextFont: UIFont {
886 UIFont.systemFont(ofSize: style.fileHeaderSubtextFontSize, weight: style.fileHeaderSubtextFontWeight)
887 }
888
889 private var fileHeaderStatusFont: UIFont {
890 UIFont.systemFont(ofSize: style.fileHeaderStatusFontSize, weight: style.fileHeaderStatusFontWeight)
891 }
892
893 private var emptyStateFont: UIFont {
894 UIFont.systemFont(ofSize: style.emptyStateFontSize, weight: style.emptyStateFontWeight)
895 }
896
897 private lazy var horizontalPanGesture: UIPanGestureRecognizer = {
898 let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleHorizontalPan(_:)))
899 gesture.delegate = self
900 gesture.cancelsTouchesInView = false
901 return gesture
902 }()
903
904 private lazy var tapGesture: UITapGestureRecognizer = {
905 let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
906 gesture.delegate = self
907 gesture.cancelsTouchesInView = false
908 return gesture
909 }()
910
911 private lazy var longPressGesture: UILongPressGestureRecognizer = {
912 let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
913 gesture.delegate = self
914 gesture.cancelsTouchesInView = false
915 gesture.minimumPressDuration = 0.28
916 return gesture
917 }()
918
919 override init(frame: CGRect) {
920 super.init(frame: frame)
921 isOpaque = true
922 contentMode = .redraw
923 addGestureRecognizer(horizontalPanGesture)
924 addGestureRecognizer(tapGesture)
925 addGestureRecognizer(longPressGesture)
926 tapGesture.require(toFail: longPressGesture)
927 }
928
929 func invalidateVisibleViewport() {
930 setNeedsDisplayForVisibleBounds()
931 }
932
933 private func setNeedsDisplayForVisibleBounds() {
934 setNeedsDisplay()
935 }
936
937 required init?(coder: NSCoder) {
938 super.init(coder: coder)
939 isOpaque = true
940 contentMode = .redraw
941 addGestureRecognizer(horizontalPanGesture)
942 addGestureRecognizer(tapGesture)
943 addGestureRecognizer(longPressGesture)
944 tapGesture.require(toFail: longPressGesture)
945 }
946
947 override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
948 guard gestureRecognizer === horizontalPanGesture else {
949 return true
950 }
951
952 let velocity = horizontalPanGesture.velocity(in: self)
953 guard abs(velocity.x) > abs(velocity.y) * 1.25 else {
954 return false
955 }
956
957 guard let panTarget = horizontalPanTarget(at: horizontalPanGesture.location(in: self)) else {
958 return false
959 }
960
961 let currentOffset = horizontalOffset(for: panTarget.fileId, kind: panTarget.kind)
962 if velocity.x > 0 && currentOffset <= 0.5 {
963 return false
964 }
965
966 return maxHorizontalOffset(for: panTarget) > 0
967 }
968
969 @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
970 guard gesture.state == .ended else {
971 return
972 }
973
974 let point = gesture.location(in: self)
975 if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
976 handleFileHeaderTap(row: stickyHeader.row, rect: stickyHeader.rect, point: point)
977 return
978 }
979
980 guard let rowIndex = rowIndex(at: verticalOffset + point.y),
981 rows.indices.contains(rowIndex) else {
982 return
983 }
984
985 let row = rows[rowIndex]
986 if row.kind == "comment" {
987 onToggleComment?(row.id)
988 return
989 }
990
991 if row.kind == "line" {
992 onPressLine?(linePressPayload(for: row, gesture: "tap"))
993 return
994 }
995
996 guard row.kind == "file" else {
997 return
998 }
999
1000 let rowY = rowOffsets[rowIndex] - verticalOffset
1001 let rect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row))
1002 handleFileHeaderTap(row: row, rect: rect, point: point)
1003 }
1004
1005 @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
1006 guard gesture.state == .began else {
1007 return
1008 }
1009
1010 let point = gesture.location(in: self)
1011 if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
1012 return
1013 }
1014
1015 guard let rowIndex = rowIndex(at: verticalOffset + point.y),
1016 rows.indices.contains(rowIndex) else {
1017 return
1018 }
1019
1020 let row = rows[rowIndex]
1021 guard row.kind == "line" else {
1022 return
1023 }
1024
1025 onPressLine?(linePressPayload(for: row, gesture: "longPress"))
1026 }
1027
1028 private func linePressPayload(for row: ReviewDiffNativeRow, gesture: String) -> [String: Any] {
1029 var payload: [String: Any] = [
1030 "rowId": row.id,
1031 "fileId": resolvedFileId(for: row),
1032 "gesture": gesture
1033 ]
1034
1035 if let oldLineNumber = row.oldLineNumber {
1036 payload["oldLineNumber"] = oldLineNumber
1037 }
1038 if let newLineNumber = row.newLineNumber {
1039 payload["newLineNumber"] = newLineNumber
1040 }
1041 if let change = row.change {
1042 payload["change"] = change
1043 }
1044
1045 return payload
1046 }
1047
1048 private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) {
1049 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect)
1050 let fileId = resolvedFileId(for: row)
1051
1052 if interactiveRects.checkbox.contains(point) {
1053 onToggleViewedFile?(fileId)
1054 return
1055 }
1056
1057 if rect.contains(point) {
1058 onToggleFile?(fileId)
1059 }
1060 }
1061
1062 func gestureRecognizer(
1063 _ gestureRecognizer: UIGestureRecognizer,
1064 shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
1065 ) -> Bool {
1066 false
1067 }
1068
1069 @objc private func handleHorizontalPan(_ gesture: UIPanGestureRecognizer) {
1070 switch gesture.state {
1071 case .began:
1072 stopHorizontalDeceleration()
1073 let panTarget = horizontalPanTarget(at: gesture.location(in: self))
1074 activePanFileId = panTarget?.fileId
1075 activePanKind = panTarget?.kind
1076 panStartHorizontalOffset = horizontalOffset(for: panTarget?.fileId, kind: panTarget?.kind)
1077 case .changed, .ended, .cancelled:
1078 guard let activePanFileId, let activePanKind else {
1079 return
1080 }
1081 let translation = gesture.translation(in: self)
1082 setHorizontalOffset(
1083 min(
1084 max(panStartHorizontalOffset - translation.x, 0),
1085 maxHorizontalOffset(for: (fileId: activePanFileId, kind: activePanKind))
1086 ),
1087 for: activePanFileId,
1088 kind: activePanKind
1089 )
1090 if gesture.state == .ended, activePanKind == .code {
1091 let velocity = -gesture.velocity(in: self).x
1092 self.activePanFileId = nil
1093 self.activePanKind = nil
1094 startHorizontalDeceleration(fileId: activePanFileId, velocity: velocity)
1095 } else if gesture.state == .cancelled {
1096 self.activePanFileId = nil
1097 self.activePanKind = nil
1098 } else if gesture.state == .ended {
1099 self.activePanFileId = nil
1100 self.activePanKind = nil
1101 }
1102 default:
1103 break
1104 }
1105 }
1106
1107 private func horizontalPanTarget(at point: CGPoint) -> (fileId: String, kind: ReviewDiffHorizontalPanKind)? {
1108 if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
1109 return (resolvedFileId(for: stickyHeader.row), .fileHeaderPath)
1110 }
1111
1112 guard let row = row(at: point) else {
1113 return nil
1114 }
1115
1116 let fileId = resolvedFileId(for: row)
1117 if row.kind == "file" {
1118 return (fileId, .fileHeaderPath)
1119 }
1120
1121 return (fileId, .code)
1122 }
1123
1124 private func stickyFileHeaderTarget() -> ReviewDiffStickyFileHeaderTarget? {
1125 guard let stickyHeader = stickyFileHeaderRowIndex() else {
1126 return nil
1127 }
1128
1129 let rowIndex = stickyHeader.rowIndex
1130 let headerTop = rowOffsets[rowIndex]
1131 guard headerTop < verticalOffset else {
1132 return nil
1133 }
1134
1135 let nextHeaderPosition = stickyHeader.position + 1
1136 let nextHeaderRowIndex = fileHeaderRowIndices.indices.contains(nextHeaderPosition)
1137 ? fileHeaderRowIndices[nextHeaderPosition]
1138 : nil
1139 let pushedY: CGFloat
1140 if let nextHeaderRowIndex {
1141 pushedY = min(0, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight)
1142 } else {
1143 pushedY = 0
1144 }
1145
1146 guard pushedY > -style.fileHeaderHeight else {
1147 return nil
1148 }
1149
1150 let rect = CGRect(
1151 x: 0,
1152 y: pushedY,
1153 width: max(bounds.width, viewportWidth),
1154 height: style.fileHeaderHeight
1155 )
1156 return ReviewDiffStickyFileHeaderTarget(rowIndex: rowIndex, row: rows[rowIndex], rect: rect)
1157 }
1158
1159 private func stickyFileHeaderRowIndex() -> ReviewDiffStickyFileHeaderIndex? {
1160 guard !fileHeaderRowIndices.isEmpty else {
1161 return nil
1162 }
1163
1164 var lowerBound = 0
1165 var upperBound = fileHeaderRowIndices.count
1166 while lowerBound < upperBound {
1167 let midpoint = (lowerBound + upperBound) / 2
1168 let rowIndex = fileHeaderRowIndices[midpoint]
1169 if rowOffsets[rowIndex] <= verticalOffset {
1170 lowerBound = midpoint + 1
1171 } else {
1172 upperBound = midpoint
1173 }
1174 }
1175
1176 let matchIndex = lowerBound - 1
1177 guard matchIndex >= 0 else {
1178 return nil
1179 }
1180 return ReviewDiffStickyFileHeaderIndex(position: matchIndex, rowIndex: fileHeaderRowIndices[matchIndex])
1181 }
1182
1183 func scrollAnchor(forFileId fileId: String) -> ReviewDiffScrollAnchor? {
1184 if let stickyHeader = stickyFileHeaderTarget(),
1185 resolvedFileId(for: stickyHeader.row) == fileId {
1186 return ReviewDiffScrollAnchor(fileId: fileId, screenY: 0)
1187 }
1188
1189 guard let headerOffset = fileHeaderOffset(forFileId: fileId) else {
1190 return nil
1191 }
1192
1193 return ReviewDiffScrollAnchor(fileId: fileId, screenY: headerOffset - verticalOffset)
1194 }
1195
1196 func fileHeaderOffset(forFileId fileId: String) -> CGFloat? {
1197 guard let rowIndex = fileHeaderRowIndex(forFileId: fileId) else {
1198 return nil
1199 }
1200
1201 return rowOffsets[rowIndex]
1202 }
1203
1204 private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {
1205 fileHeaderRowIndices.first { rowIndex in
1206 rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId
1207 }
1208 }
1209
1210 private func row(at point: CGPoint) -> ReviewDiffNativeRow? {
1211 guard let rowIndex = rowIndex(at: verticalOffset + point.y) else {
1212 return nil
1213 }
1214 guard rows.indices.contains(rowIndex) else {
1215 return nil
1216 }
1217 return rows[rowIndex]
1218 }
1219
1220 private func rowIndex(at absoluteY: CGFloat) -> Int? {
1221 guard !rows.isEmpty else {
1222 return nil
1223 }
1224
1225 var lowerBound = 0
1226 var upperBound = rows.count - 1
1227 while lowerBound <= upperBound {
1228 let midpoint = (lowerBound + upperBound) / 2
1229 let rowStart = rowOffsets[midpoint]
1230 let rowEnd = rowStart + height(for: rows[midpoint])
1231
1232 if absoluteY < rowStart {
1233 upperBound = midpoint - 1
1234 } else if absoluteY >= rowEnd {
1235 lowerBound = midpoint + 1
1236 } else {
1237 return midpoint
1238 }
1239 }
1240
1241 return nil
1242 }
1243
1244 private func firstVisibleRowIndex(atOrAfter absoluteY: CGFloat) -> Int? {
1245 guard !rows.isEmpty else {
1246 return nil
1247 }
1248
1249 var lowerBound = 0
1250 var upperBound = rows.count
1251 while lowerBound < upperBound {
1252 let midpoint = (lowerBound + upperBound) / 2
1253 let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint])
1254
1255 if rowEnd < absoluteY {
1256 lowerBound = midpoint + 1
1257 } else {
1258 upperBound = midpoint
1259 }
1260 }
1261
1262 return lowerBound < rows.count ? lowerBound : nil
1263 }
1264
1265 private func lastVisibleRowIndex(atOrBefore absoluteY: CGFloat) -> Int? {
1266 guard !rows.isEmpty else {
1267 return nil
1268 }
1269
1270 var lowerBound = 0
1271 var upperBound = rows.count
1272 while lowerBound < upperBound {
1273 let midpoint = (lowerBound + upperBound) / 2
1274 let rowStart = rowOffsets[midpoint]
1275
1276 if rowStart <= absoluteY {
1277 lowerBound = midpoint + 1
1278 } else {
1279 upperBound = midpoint
1280 }
1281 }
1282
1283 let index = lowerBound - 1
1284 return index >= 0 ? index : nil
1285 }
1286
1287 private func resolvedFileId(for row: ReviewDiffNativeRow) -> String {
1288 if let fileId = row.fileId {
1289 return fileId
1290 }
1291 if let range = row.id.range(of: ":header") ?? row.id.range(of: ":hunk:") ?? row.id.range(of: ":line:") {
1292 return String(row.id[..<range.lowerBound])
1293 }
1294 return row.filePath ?? row.id
1295 }
1296
1297 private func horizontalOffset(
1298 for fileId: String?,
1299 kind: ReviewDiffHorizontalPanKind? = .code
1300 ) -> CGFloat {
1301 guard let fileId else {
1302 return 0
1303 }
1304 if kind == .fileHeaderPath {
1305 return headerPathOffsetsByFileId[fileId] ?? 0
1306 }
1307 return horizontalOffsetsByFileId[fileId] ?? 0
1308 }
1309
1310 private func setHorizontalOffset(
1311 _ offset: CGFloat,
1312 for fileId: String,
1313 kind: ReviewDiffHorizontalPanKind = .code
1314 ) {
1315 if kind == .fileHeaderPath {
1316 headerPathOffsetsByFileId[fileId] = offset
1317 setNeedsDisplayForVisibleBounds()
1318 return
1319 }
1320 horizontalOffsetsByFileId[fileId] = offset
1321 setNeedsDisplayForVisibleBounds()
1322 }
1323
1324 private func clampHorizontalOffsets() {
1325 for (fileId, offset) in horizontalOffsetsByFileId {
1326 horizontalOffsetsByFileId[fileId] = min(offset, maxHorizontalOffset(for: (fileId: fileId, kind: .code)))
1327 }
1328 for (fileId, offset) in headerPathOffsetsByFileId {
1329 headerPathOffsetsByFileId[fileId] = min(
1330 offset,
1331 maxHorizontalOffset(for: (fileId: fileId, kind: .fileHeaderPath))
1332 )
1333 }
1334 }
1335
1336 private func maxHorizontalOffset(for target: (fileId: String, kind: ReviewDiffHorizontalPanKind)) -> CGFloat {
1337 if target.kind == .fileHeaderPath,
1338 let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) {
1339 return maxHeaderPathOffset(for: row)
1340 }
1341
1342 return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX))
1343 }
1344
1345 private func contentWidth(for fileId: String) -> CGFloat {
1346 contentWidthsByFileId[fileId] ?? min(style.contentWidth, max(viewportWidth, 0))
1347 }
1348
1349 func currentVisibleRowRange() -> (firstRowIndex: Int, lastRowIndex: Int)? {
1350 guard !rows.isEmpty else {
1351 return nil
1352 }
1353
1354 let visibleMinY = verticalOffset
1355 let visibleMaxY = verticalOffset + max(bounds.height, 1)
1356 guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: visibleMinY),
1357 let lastRowIndex = lastVisibleRowIndex(atOrBefore: visibleMaxY),
1358 firstRowIndex <= lastRowIndex else {
1359 return nil
1360 }
1361
1362 return (firstRowIndex: firstRowIndex, lastRowIndex: lastRowIndex)
1363 }
1364
1365 private func maxHeaderPathOffset(for row: ReviewDiffNativeRow) -> CGFloat {
1366 let fullRect = CGRect(x: 0, y: 0, width: max(bounds.width, viewportWidth), height: height(for: row))
1367 let layout = fileHeaderPathLayout(for: row, cardRect: fullRect)
1368 let pathWidth = textWidth(layout.displayPath, font: fileHeaderFont)
1369 return max(0, pathWidth - layout.rect.width)
1370 }
1371
1372 private func startHorizontalDeceleration(fileId: String, velocity: CGFloat) {
1373 guard abs(velocity) > 80 else {
1374 return
1375 }
1376
1377 let currentOffset = horizontalOffset(for: fileId)
1378 let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code))
1379 if (currentOffset <= 0 && velocity < 0) || (currentOffset >= maxOffset && velocity > 0) {
1380 return
1381 }
1382
1383 stopHorizontalDeceleration()
1384 deceleratingFileId = fileId
1385 horizontalVelocity = velocity
1386 lastDecelerationTimestamp = 0
1387
1388 let displayLink = CADisplayLink(target: self, selector: #selector(stepHorizontalDeceleration(_:)))
1389 displayLink.add(to: .main, forMode: .common)
1390 decelerationDisplayLink = displayLink
1391 }
1392
1393 @objc private func stepHorizontalDeceleration(_ displayLink: CADisplayLink) {
1394 guard let fileId = deceleratingFileId else {
1395 stopHorizontalDeceleration()
1396 return
1397 }
1398
1399 if lastDecelerationTimestamp == 0 {
1400 lastDecelerationTimestamp = displayLink.timestamp
1401 return
1402 }
1403
1404 let dt = max(0, displayLink.timestamp - lastDecelerationTimestamp)
1405 lastDecelerationTimestamp = displayLink.timestamp
1406
1407 let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code))
1408 let nextOffset = horizontalOffset(for: fileId) + horizontalVelocity * CGFloat(dt)
1409 let clampedOffset = min(max(nextOffset, 0), maxOffset)
1410 setHorizontalOffset(clampedOffset, for: fileId)
1411
1412 // UIScrollView deceleration rates are expressed per millisecond.
1413 horizontalVelocity *= CGFloat(pow(Double(UIScrollView.DecelerationRate.normal.rawValue), dt * 1000))
1414
1415 if abs(horizontalVelocity) < 20 || clampedOffset <= 0 || clampedOffset >= maxOffset {
1416 stopHorizontalDeceleration()
1417 }
1418 }
1419
1420 private func stopHorizontalDeceleration() {
1421 decelerationDisplayLink?.invalidate()
1422 decelerationDisplayLink = nil
1423 deceleratingFileId = nil
1424 horizontalVelocity = 0
1425 lastDecelerationTimestamp = 0
1426 }
1427
1428 deinit {
1429 stopHorizontalDeceleration()
1430 }
1431
1432 override func draw(_ rect: CGRect) {
1433 let drawStartedAt = CACurrentMediaTime()
1434 guard let context = UIGraphicsGetCurrentContext() else {
1435 return
1436 }
1437
1438 theme.background.setFill()
1439 context.fill(rect)
1440
1441 if rows.isEmpty {
1442 drawEmptyState(rect)
1443 return
1444 }
1445
1446 let visibleMinY = verticalOffset + rect.minY
1447 let visibleMaxY = verticalOffset + rect.maxY
1448 let overscan = max(style.rowHeight, style.fileHeaderHeight) * 4
1449 let rangeMinY = max(0, visibleMinY - overscan)
1450 let rangeMaxY = visibleMaxY + overscan
1451 guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: rangeMinY),
1452 let lastRowIndex = lastVisibleRowIndex(atOrBefore: rangeMaxY),
1453 firstRowIndex <= lastRowIndex else {
1454 return
1455 }
1456
1457 var drawnRowCount = 0
1458 for rowIndex in firstRowIndex...lastRowIndex {
1459 let rowStart = rowOffsets[rowIndex]
1460 let rowHeight = height(for: rows[rowIndex])
1461 if rowHeight <= 0 {
1462 continue
1463 }
1464 let rowEnd = rowStart + rowHeight
1465 if rowEnd < visibleMinY || rowStart > visibleMaxY {
1466 continue
1467 }
1468 drawRow(rows[rowIndex], rowIndex: rowIndex, context: context)
1469 drawnRowCount += 1
1470 }
1471 drawStickyFileHeader(context: context)
1472
1473 maybeEmitDrawMetrics(
1474 drawnRowCount: drawnRowCount,
1475 durationMs: (CACurrentMediaTime() - drawStartedAt) * 1000,
1476 firstRowIndex: firstRowIndex,
1477 lastRowIndex: lastRowIndex
1478 )
1479 }
1480
1481 private func maybeEmitDrawMetrics(
1482 drawnRowCount: Int,
1483 durationMs: Double,
1484 firstRowIndex: Int,
1485 lastRowIndex: Int
1486 ) {
1487 guard !isVerticalScrollActive else {
1488 return
1489 }
1490
1491 let now = CACurrentMediaTime()
1492 guard now - lastDrawMetricsTimestamp >= 1 else {
1493 return
1494 }
1495
1496 lastDrawMetricsTimestamp = now
1497 onDrawMetrics?([
1498 "drawnRows": drawnRowCount,
1499 "durationMs": durationMs,
1500 "firstRowIndex": firstRowIndex,
1501 "lastRowIndex": lastRowIndex,
1502 "scannedRows": lastRowIndex - firstRowIndex + 1,
1503 "totalRows": rows.count,
1504 ])
1505 }
1506
1507 private func drawEmptyState(_ rect: CGRect) {
1508 let message = "No native diff rows"
1509 let attributes: [NSAttributedString.Key: Any] = [
1510 .font: emptyStateFont,
1511 .foregroundColor: theme.mutedText,
1512 ]
1513 message.draw(at: CGPoint(x: 16, y: rect.minY + 16), withAttributes: attributes)
1514 }
1515
1516 private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) {
1517 let rowY = rowOffsets[rowIndex] - verticalOffset
1518 let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row))
1519
1520 switch row.kind {
1521 case "file":
1522 drawFileRow(row, rect: fullRect, context: context)
1523 case "hunk":
1524 drawHunkRow(row, rect: fullRect, context: context)
1525 case "notice":
1526 drawNoticeRow(row, rect: fullRect, context: context)
1527 case "comment":
1528 drawCommentRow(row, rect: fullRect, context: context)
1529 default:
1530 drawCodeRow(row, rect: fullRect, context: context)
1531 }
1532 }
1533
1534 private func drawStickyFileHeader(context: CGContext) {
1535 guard let stickyHeader = stickyFileHeaderTarget() else {
1536 return
1537 }
1538
1539 drawFileRow(stickyHeader.row, rect: stickyHeader.rect, context: context)
1540 }
1541
1542 private func drawFileRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
1543 theme.background.setFill()
1544 context.fill(rect)
1545
1546 let cardRect = rect
1547 theme.headerBackground.setFill()
1548 context.fill(cardRect)
1549
1550 let hairline = 1 / UIScreen.main.scale
1551 theme.border.setFill()
1552 context.fill(CGRect(x: cardRect.minX, y: cardRect.maxY - hairline, width: cardRect.width, height: hairline))
1553
1554 let centerY = cardRect.midY
1555 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect)
1556 let fileId = resolvedFileId(for: row)
1557 drawDisclosureChevron(
1558 rect: interactiveRects.chevron,
1559 color: theme.mutedText,
1560 collapsed: collapsedFileIds.contains(fileId)
1561 )
1562
1563 drawFileIcon(rect: interactiveRects.icon, changeType: row.changeType)
1564
1565 drawViewedCheckbox(rect: interactiveRects.checkbox, checked: viewedFileIds.contains(fileId))
1566
1567 let deletions = row.deletions ?? 0
1568 let additions = row.additions ?? 0
1569 let deleteText = "-\(deletions)"
1570 let addText = "+\(additions)"
1571 let deleteWidth = textWidth(deleteText, font: fileHeaderMetaFont)
1572 let addWidth = textWidth(addText, font: fileHeaderMetaFont)
1573 let countsGap = min(style.fileHeaderCountGap, 4)
1574 let countsWidth = deleteWidth + countsGap + addWidth
1575 let countsX = interactiveRects.checkbox.minX - 10 - countsWidth
1576 drawSingleLineText(
1577 deleteText,
1578 rect: CGRect(x: countsX, y: centerY - 9, width: deleteWidth, height: 18),
1579 color: theme.deleteText,
1580 font: fileHeaderMetaFont
1581 )
1582 drawSingleLineText(
1583 addText,
1584 rect: CGRect(x: countsX + deleteWidth + countsGap, y: centerY - 9, width: addWidth, height: 18),
1585 color: theme.addText,
1586 font: fileHeaderMetaFont
1587 )
1588
1589 let pathLayout = fileHeaderPathLayout(for: row, cardRect: cardRect)
1590 let pathOffset = horizontalOffset(for: resolvedFileId(for: row), kind: .fileHeaderPath)
1591 drawSingleLineText(
1592 pathLayout.displayPath,
1593 rect: pathLayout.rect,
1594 color: theme.text,
1595 font: fileHeaderFont,
1596 horizontalOffset: pathOffset
1597 )
1598 drawFileHeaderPathScrollFade(row, pathRect: pathLayout.rect, horizontalOffset: pathOffset, context: context)
1599 }
1600
1601 private func drawNoticeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
1602 guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
1603 return
1604 }
1605
1606 theme.background.setFill()
1607 context.fill(rect)
1608
1609 let hairline = 1 / UIScreen.main.scale
1610 theme.border.withAlphaComponent(0.65).setFill()
1611 context.fill(CGRect(x: 0, y: rect.maxY - hairline, width: rect.width, height: hairline))
1612
1613 let iconSize: CGFloat = 16
1614 let iconRect = CGRect(
1615 x: style.fileHeaderHorizontalPadding + 2,
1616 y: rect.midY - iconSize / 2,
1617 width: iconSize,
1618 height: iconSize
1619 )
1620 drawNoticeIcon(rect: iconRect, color: theme.mutedText)
1621
1622 drawSingleLineText(
1623 row.text ?? "",
1624 rect: CGRect(
1625 x: iconRect.maxX + 10,
1626 y: rect.midY - fileHeaderSubtextFont.lineHeight / 2,
1627 width: max(24, viewportWidth - iconRect.maxX - 10 - style.fileHeaderHorizontalPadding),
1628 height: fileHeaderSubtextFont.lineHeight
1629 ),
1630 color: theme.mutedText,
1631 font: fileHeaderSubtextFont
1632 )
1633 }
1634
1635 private func drawCommentRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
1636 guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
1637 return
1638 }
1639
1640 theme.background.setFill()
1641 context.fill(rect)
1642
1643 let isCollapsed = collapsedCommentIds.contains(row.id)
1644 let cardInset = CGFloat(8)
1645 let cardRect = rect.insetBy(dx: cardInset, dy: 5)
1646 let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 10)
1647 theme.headerBackground.setFill()
1648 cardPath.fill()
1649 theme.border.withAlphaComponent(0.85).setStroke()
1650 cardPath.lineWidth = 1
1651 cardPath.stroke()
1652
1653 let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16)
1654 drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed)
1655
1656 let title = "Comment on \(row.commentRangeLabel ?? "line")"
1657 drawSingleLineText(
1658 title,
1659 rect: CGRect(
1660 x: chevronRect.maxX + 10,
1661 y: cardRect.minY + 8,
1662 width: max(24, cardRect.width - chevronRect.maxX - 28),
1663 height: fileHeaderSubtextFont.lineHeight + 4
1664 ),
1665 color: theme.mutedText,
1666 font: fileHeaderSubtextFont
1667 )
1668
1669 guard !isCollapsed else {
1670 return
1671 }
1672
1673 let body = row.commentText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1674 drawMultilineText(
1675 body.isEmpty ? "Comment" : body,
1676 rect: CGRect(
1677 x: cardRect.minX + 18,
1678 y: cardRect.minY + 42,
1679 width: max(24, cardRect.width - 36),
1680 height: max(20, cardRect.height - 56)
1681 ),
1682 color: theme.text,
1683 font: fileHeaderSubtextFont,
1684 maximumLineCount: 3
1685 )
1686 }
1687
1688 private func fileHeaderPathLayout(
1689 for row: ReviewDiffNativeRow,
1690 cardRect: CGRect
1691 ) -> ReviewDiffFileHeaderPathLayout {
1692 let centerY = cardRect.midY
1693 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect)
1694
1695 let deletions = row.deletions ?? 0
1696 let additions = row.additions ?? 0
1697 let deleteWidth = textWidth("-\(deletions)", font: fileHeaderMetaFont)
1698 let addWidth = textWidth("+\(additions)", font: fileHeaderMetaFont)
1699 let countsGap = min(style.fileHeaderCountGap, 4)
1700 let countsWidth = deleteWidth + countsGap + addWidth
1701 let countsX = interactiveRects.checkbox.minX - 10 - countsWidth
1702 let pathX = interactiveRects.icon.maxX + 10
1703 let pathWidth = max(CGFloat(24), countsX - pathX - 12)
1704 let displayPath: String
1705 if let previousPath = row.previousPath, previousPath != row.filePath, let filePath = row.filePath {
1706 displayPath = "\(previousPath) -> \(filePath)"
1707 } else {
1708 displayPath = row.filePath ?? ""
1709 }
1710
1711 return ReviewDiffFileHeaderPathLayout(
1712 displayPath: displayPath,
1713 rect: CGRect(x: pathX, y: centerY - 10, width: pathWidth, height: 20)
1714 )
1715 }
1716
1717 private func fileHeaderInteractiveRects(
1718 for row: ReviewDiffNativeRow,
1719 cardRect: CGRect
1720 ) -> ReviewDiffFileHeaderInteractiveRects {
1721 let centerY = cardRect.midY
1722 let horizontalPadding = style.fileHeaderHorizontalPadding
1723 let chevronRect = CGRect(x: cardRect.minX + horizontalPadding, y: centerY - 10, width: 20, height: 20)
1724 let iconRect = CGRect(x: chevronRect.maxX + 8, y: centerY - 10, width: 20, height: 20)
1725 let checkboxRect = CGRect(x: cardRect.maxX - horizontalPadding - 20, y: centerY - 10, width: 20, height: 20)
1726 return ReviewDiffFileHeaderInteractiveRects(
1727 chevron: chevronRect,
1728 icon: iconRect,
1729 checkbox: checkboxRect
1730 )
1731 }
1732
1733 private func fileStatusText(_ changeType: String?) -> String {
1734 switch changeType {
1735 case "new":
1736 return "A"
1737 case "deleted":
1738 return "D"
1739 case "renamed":
1740 return "R"
1741 default:
1742 return ""
1743 }
1744 }
1745
1746 private func fileStatusColor(_ changeType: String?) -> UIColor {
1747 switch changeType {
1748 case "new":
1749 return theme.addText
1750 case "deleted":
1751 return theme.deleteText
1752 case "renamed", "rename-pure", "rename-changed":
1753 return theme.hunkText
1754 default:
1755 return theme.hunkText
1756 }
1757 }
1758
1759 private func drawStatusPill(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
1760 let path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height / 2)
1761 color.withAlphaComponent(0.12).setFill()
1762 path.fill()
1763 drawCenteredText(text, rect: rect, color: color, font: font)
1764 }
1765
1766 private func drawFileIcon(rect: CGRect, changeType: String?) {
1767 let color = fileStatusColor(changeType)
1768 let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6)
1769 color.setStroke()
1770 outerPath.lineWidth = 2
1771 outerPath.stroke()
1772
1773 if isRenameChange(changeType) {
1774 drawRenameChevronIcon(rect: rect.insetBy(dx: 4.5, dy: 5), color: color)
1775 return
1776 }
1777
1778 let dotRect = CGRect(x: rect.midX - 3, y: rect.midY - 3, width: 6, height: 6)
1779 color.setFill()
1780 UIBezierPath(ovalIn: dotRect).fill()
1781 }
1782
1783 private func isRenameChange(_ changeType: String?) -> Bool {
1784 changeType == "renamed" || changeType == "rename-pure" || changeType == "rename-changed"
1785 }
1786
1787 private func drawDisclosureChevron(rect: CGRect, color: UIColor, collapsed: Bool) {
1788 guard let context = UIGraphicsGetCurrentContext() else {
1789 return
1790 }
1791
1792 context.saveGState()
1793 context.setStrokeColor(color.cgColor)
1794 context.setLineWidth(2)
1795 context.setLineCap(.round)
1796 context.setLineJoin(.round)
1797 if collapsed {
1798 context.move(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.minY + rect.height * 0.28))
1799 context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.60, y: rect.midY))
1800 context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.maxY - rect.height * 0.28))
1801 } else {
1802 context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.minY + rect.height * 0.42))
1803 context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.62))
1804 context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.28, y: rect.minY + rect.height * 0.42))
1805 }
1806 context.strokePath()
1807 context.restoreGState()
1808 }
1809
1810 private func drawRenameChevronIcon(rect: CGRect, color: UIColor) {
1811 guard let context = UIGraphicsGetCurrentContext() else {
1812 return
1813 }
1814
1815 context.saveGState()
1816 context.setStrokeColor(color.cgColor)
1817 context.setLineWidth(1.8)
1818 context.setLineCap(.round)
1819 context.setLineJoin(.round)
1820
1821 let chevronWidth = min(rect.width * 0.28, 3.6)
1822 let chevronHeight = min(rect.height, 8)
1823 let gap = min(rect.width * 0.18, 2.4)
1824 let totalWidth = chevronWidth * 2 + gap
1825 let startX = rect.midX - totalWidth / 2
1826 let topY = rect.midY - chevronHeight / 2
1827 let bottomY = rect.midY + chevronHeight / 2
1828
1829 for x in [startX, startX + chevronWidth + gap] {
1830 context.move(to: CGPoint(x: x, y: topY))
1831 context.addLine(to: CGPoint(x: x + chevronWidth, y: rect.midY))
1832 context.addLine(to: CGPoint(x: x, y: bottomY))
1833 }
1834
1835 context.strokePath()
1836 context.restoreGState()
1837 }
1838
1839 private func drawViewedCheckbox(rect: CGRect, checked: Bool) {
1840 let path = UIBezierPath(roundedRect: rect, cornerRadius: 6)
1841 if checked {
1842 theme.hunkText.setFill()
1843 path.fill()
1844 }
1845 (checked ? theme.hunkText : theme.mutedText).setStroke()
1846 path.lineWidth = 1.8
1847 path.stroke()
1848
1849 guard checked, let context = UIGraphicsGetCurrentContext() else {
1850 return
1851 }
1852
1853 context.saveGState()
1854 context.setStrokeColor(theme.background.cgColor)
1855 context.setLineWidth(2)
1856 context.setLineCap(.round)
1857 context.setLineJoin(.round)
1858 context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.midY))
1859 context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.44, y: rect.maxY - rect.height * 0.30))
1860 context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.25, y: rect.minY + rect.height * 0.30))
1861 context.strokePath()
1862 context.restoreGState()
1863 }
1864
1865 private func drawNoticeIcon(rect: CGRect, color: UIColor) {
1866 guard let context = UIGraphicsGetCurrentContext() else {
1867 return
1868 }
1869
1870 context.saveGState()
1871 context.setStrokeColor(color.cgColor)
1872 context.setLineWidth(1.7)
1873 context.setLineCap(.round)
1874 context.setLineJoin(.round)
1875
1876 context.strokeEllipse(in: rect.insetBy(dx: 1, dy: 1))
1877 context.move(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.30))
1878 context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.58))
1879 context.strokePath()
1880 color.setFill()
1881 context.fillEllipse(in: CGRect(x: rect.midX - 1, y: rect.maxY - rect.height * 0.30, width: 2, height: 2))
1882 context.restoreGState()
1883 }
1884
1885 private func drawCenteredText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
1886 let paragraphStyle = NSMutableParagraphStyle()
1887 paragraphStyle.alignment = .center
1888 let attributes: [NSAttributedString.Key: Any] = [
1889 .font: font,
1890 .foregroundColor: color,
1891 .paragraphStyle: paragraphStyle,
1892 ]
1893 (text as NSString).draw(in: rect, withAttributes: attributes)
1894 }
1895
1896 private func drawHunkRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
1897 let fileId = resolvedFileId(for: row)
1898 let horizontalOffset = horizontalOffset(for: fileId)
1899 let contentWidth = contentWidth(for: fileId)
1900 theme.hunkBackground.setFill()
1901 context.fill(rect)
1902
1903 context.saveGState()
1904 context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight))
1905 drawText(
1906 row.text ?? "",
1907 rect: CGRect(
1908 x: codeStartX - horizontalOffset,
1909 y: centeredTextY(in: rect, font: hunkFont),
1910 width: contentWidth,
1911 height: hunkFont.lineHeight
1912 ),
1913 color: theme.hunkText,
1914 font: hunkFont
1915 )
1916 context.restoreGState()
1917 }
1918
1919 private func drawCodeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
1920 let fileId = resolvedFileId(for: row)
1921 let horizontalOffset = horizontalOffset(for: fileId)
1922 let contentWidth = contentWidth(for: fileId)
1923 let change = row.change ?? "context"
1924 rowBackground(for: change).setFill()
1925 context.fill(rect)
1926
1927 if change == "add" {
1928 theme.addBar.setFill()
1929 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight))
1930 } else if change == "delete" {
1931 drawDeleteStripes(
1932 rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight),
1933 context: context
1934 )
1935 }
1936
1937 drawSelectionOverlay(row, rect: rect, context: context)
1938
1939 let lineNumber = row.newLineNumber ?? row.oldLineNumber
1940 if let lineNumber {
1941 drawRightAlignedText(
1942 "\(lineNumber)",
1943 rect: CGRect(
1944 x: style.changeBarWidth,
1945 y: centeredTextY(in: rect, font: lineNumberFont),
1946 width: style.gutterWidth - style.codePadding,
1947 height: lineNumberFont.lineHeight
1948 ),
1949 color: lineNumberColor(for: change),
1950 font: lineNumberFont
1951 )
1952 }
1953
1954 context.saveGState()
1955 context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight))
1956 let codeTextRect = CGRect(
1957 x: codeStartX - horizontalOffset,
1958 y: centeredTextY(in: rect, font: codeFont),
1959 width: contentWidth,
1960 height: codeFont.lineHeight
1961 )
1962 drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset)
1963 if let tokens = tokensByRowId[row.id], !tokens.isEmpty {
1964 drawTokenText(
1965 rowId: row.id,
1966 tokens,
1967 rect: codeTextRect,
1968 fallbackColor: theme.text,
1969 font: codeFont
1970 )
1971 } else {
1972 drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont)
1973 }
1974 context.restoreGState()
1975 }
1976
1977 private func drawSelectionOverlay(
1978 _ row: ReviewDiffNativeRow,
1979 rect: CGRect,
1980 context: CGContext
1981 ) {
1982 guard selectedRowIds.contains(row.id) else {
1983 return
1984 }
1985
1986 let selectionColor = theme.hunkText.withAlphaComponent(0.22)
1987 selectionColor.setFill()
1988 context.fill(rect)
1989
1990 theme.hunkText.withAlphaComponent(0.95).setFill()
1991 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
1992 }
1993
1994 private func drawWordDiffRanges(
1995 _ row: ReviewDiffNativeRow,
1996 rowRect: CGRect,
1997 context: CGContext,
1998 horizontalOffset: CGFloat
1999 ) {
2000 guard let ranges = row.wordDiffRanges, !ranges.isEmpty else {
2001 return
2002 }
2003
2004 let change = row.change ?? "context"
2005 guard change == "add" || change == "delete" else {
2006 return
2007 }
2008
2009 let fillColor: UIColor
2010 if change == "add" {
2011 fillColor = theme.addBar.withAlphaComponent(0.28)
2012 } else {
2013 fillColor = theme.deleteBar.withAlphaComponent(0.28)
2014 }
2015 let highlightHeight = max(4, min(rowRect.height - 4, codeFont.lineHeight))
2016 let highlightY = rowRect.midY - highlightHeight / 2
2017
2018 fillColor.setFill()
2019 for range in ranges {
2020 guard range.end > range.start else {
2021 continue
2022 }
2023
2024 let startX = codeStartX - horizontalOffset + CGFloat(range.start) * codeCharacterWidth
2025 let width = max(2, CGFloat(range.end - range.start) * codeCharacterWidth)
2026 let highlightRect = CGRect(
2027 x: startX,
2028 y: highlightY,
2029 width: width,
2030 height: highlightHeight
2031 )
2032 UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill()
2033 }
2034 }
2035
2036 private func rowBackground(for change: String) -> UIColor {
2037 if change == "add" {
2038 return theme.addBackground
2039 }
2040 if change == "delete" {
2041 return theme.deleteBackground
2042 }
2043 return theme.background
2044 }
2045
2046 private func lineNumberColor(for change: String) -> UIColor {
2047 if change == "add" {
2048 return theme.addText
2049 }
2050 if change == "delete" {
2051 return theme.deleteText
2052 }
2053 return theme.mutedText
2054 }
2055
2056 private func drawDeleteStripes(rect: CGRect, context: CGContext) {
2057 theme.deleteBar.setFill()
2058 var y = rect.minY
2059 while y < rect.maxY {
2060 context.fill(CGRect(x: rect.minX, y: y, width: rect.width, height: 1))
2061 y += 2
2062 }
2063 }
2064
2065 private func drawText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
2066 let attributes: [NSAttributedString.Key: Any] = [
2067 .font: font,
2068 .foregroundColor: color,
2069 .ligature: 0,
2070 ]
2071 (text as NSString).draw(in: rect, withAttributes: attributes)
2072 }
2073
2074 private func drawMultilineText(
2075 _ text: String,
2076 rect: CGRect,
2077 color: UIColor,
2078 font: UIFont,
2079 maximumLineCount: Int
2080 ) {
2081 let paragraphStyle = NSMutableParagraphStyle()
2082 paragraphStyle.lineBreakMode = .byTruncatingTail
2083 paragraphStyle.lineSpacing = 2
2084
2085 let attributes: [NSAttributedString.Key: Any] = [
2086 .font: font,
2087 .foregroundColor: color,
2088 .paragraphStyle: paragraphStyle,
2089 .ligature: 0,
2090 ]
2091 let maxHeight = CGFloat(maximumLineCount) * (font.lineHeight + paragraphStyle.lineSpacing)
2092 (text as NSString).draw(
2093 in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)),
2094 withAttributes: attributes
2095 )
2096 }
2097
2098 private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat {
2099 rect.midY - font.lineHeight / 2
2100 }
2101
2102 private func drawSingleLineText(
2103 _ text: String,
2104 rect: CGRect,
2105 color: UIColor,
2106 font: UIFont,
2107 horizontalOffset: CGFloat = 0
2108 ) {
2109 guard let context = UIGraphicsGetCurrentContext() else {
2110 return
2111 }
2112
2113 let attributes: [NSAttributedString.Key: Any] = [
2114 .font: font,
2115 .foregroundColor: color,
2116 .ligature: 0,
2117 ]
2118 context.saveGState()
2119 context.clip(to: rect)
2120 let textY = rect.midY - font.lineHeight / 2
2121 (text as NSString).draw(
2122 at: CGPoint(x: rect.minX - horizontalOffset, y: textY),
2123 withAttributes: attributes
2124 )
2125 context.restoreGState()
2126 }
2127
2128 private func drawFileHeaderPathScrollFade(
2129 _ row: ReviewDiffNativeRow,
2130 pathRect: CGRect,
2131 horizontalOffset: CGFloat,
2132 context: CGContext
2133 ) {
2134 let maxOffset = maxHeaderPathOffset(for: row)
2135 guard maxOffset > 0, pathRect.width > 0 else {
2136 return
2137 }
2138
2139 let fadeWidth = min(CGFloat(28), pathRect.width / 3)
2140 if horizontalOffset > 0.5 {
2141 drawHorizontalFade(
2142 rect: CGRect(x: pathRect.minX, y: pathRect.minY, width: fadeWidth, height: pathRect.height),
2143 color: theme.headerBackground,
2144 fadesToRight: false,
2145 context: context
2146 )
2147 }
2148
2149 if horizontalOffset < maxOffset - 0.5 {
2150 drawHorizontalFade(
2151 rect: CGRect(x: pathRect.maxX - fadeWidth, y: pathRect.minY, width: fadeWidth, height: pathRect.height),
2152 color: theme.headerBackground,
2153 fadesToRight: true,
2154 context: context
2155 )
2156 }
2157 }
2158
2159 private func drawHorizontalFade(
2160 rect: CGRect,
2161 color: UIColor,
2162 fadesToRight: Bool,
2163 context: CGContext
2164 ) {
2165 guard rect.width > 0,
2166 let gradient = CGGradient(
2167 colorsSpace: CGColorSpaceCreateDeviceRGB(),
2168 colors: [
2169 color.withAlphaComponent(fadesToRight ? 0 : 1).cgColor,
2170 color.withAlphaComponent(fadesToRight ? 1 : 0).cgColor,
2171 ] as CFArray,
2172 locations: [0, 1]
2173 ) else {
2174 return
2175 }
2176
2177 context.saveGState()
2178 context.clip(to: rect)
2179 context.drawLinearGradient(
2180 gradient,
2181 start: CGPoint(x: rect.minX, y: rect.midY),
2182 end: CGPoint(x: rect.maxX, y: rect.midY),
2183 options: []
2184 )
2185 context.restoreGState()
2186 }
2187
2188 private func textWidth(_ text: String, font: UIFont) -> CGFloat {
2189 let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0]
2190 return ceil((text as NSString).size(withAttributes: attributes).width)
2191 }
2192
2193 private func monospaceCharacterWidth(font: UIFont) -> CGFloat {
2194 let sampleLength = 64
2195 let sample = String(repeating: "M", count: sampleLength)
2196 let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0]
2197 return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength)
2198 }
2199
2200 private func drawTokenText(
2201 rowId: String,
2202 _ tokens: [ReviewDiffNativeToken],
2203 rect: CGRect,
2204 fallbackColor: UIColor,
2205 font: UIFont
2206 ) {
2207 let attributedText = tokenAttributedString(
2208 rowId: rowId,
2209 tokens: tokens,
2210 fallbackColor: fallbackColor,
2211 font: font
2212 )
2213 attributedText.draw(in: rect)
2214 }
2215
2216 private func tokenAttributedString(
2217 rowId: String,
2218 tokens: [ReviewDiffNativeToken],
2219 fallbackColor: UIColor,
2220 font: UIFont
2221 ) -> NSAttributedString {
2222 if let cached = tokenAttributedStringsByRowId[rowId] {
2223 return cached
2224 }
2225
2226 let attributedString = NSMutableAttributedString(string: "")
2227 for token in tokens where !token.content.isEmpty {
2228 attributedString.append(
2229 NSAttributedString(
2230 string: token.content,
2231 attributes: [
2232 .font: font,
2233 .foregroundColor: tokenColor(for: token.color, fallbackColor: fallbackColor),
2234 .ligature: 0,
2235 ]
2236 )
2237 )
2238 }
2239
2240 tokenAttributedStringsByRowId[rowId] = attributedString
2241 return attributedString
2242 }
2243
2244 private func tokenColor(for hex: String?, fallbackColor: UIColor) -> UIColor {
2245 guard let hex, !hex.isEmpty else {
2246 return fallbackColor
2247 }
2248
2249 if let color = tokenColorsByHex[hex] {
2250 return color
2251 }
2252
2253 guard let color = UIColor(reviewDiffHex: hex) else {
2254 return fallbackColor
2255 }
2256
2257 tokenColorsByHex[hex] = color
2258 return color
2259 }
2260
2261 private func drawRightAlignedText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
2262 let paragraphStyle = NSMutableParagraphStyle()
2263 paragraphStyle.alignment = .right
2264 let attributes: [NSAttributedString.Key: Any] = [
2265 .font: font,
2266 .foregroundColor: color,
2267 .paragraphStyle: paragraphStyle,
2268 ]
2269 (text as NSString).draw(in: rect, withAttributes: attributes)
2270 }
2271}
2272
2273private extension UIColor {
2274 convenience init?(reviewDiffHex hex: String?) {
2275 guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
2276 return nil
2277 }
2278
2279 if value.hasPrefix("#") {
2280 value.removeFirst()
2281 }
2282
2283 guard value.count == 6 || value.count == 8 else {
2284 return nil
2285 }
2286
2287 var rawValue: UInt64 = 0
2288 guard Scanner(string: value).scanHexInt64(&rawValue) else {
2289 return nil
2290 }
2291
2292 let red: CGFloat
2293 let green: CGFloat
2294 let blue: CGFloat
2295 let alpha: CGFloat
2296
2297 if value.count == 8 {
2298 red = CGFloat((rawValue >> 24) & 0xff) / 255
2299 green = CGFloat((rawValue >> 16) & 0xff) / 255
2300 blue = CGFloat((rawValue >> 8) & 0xff) / 255
2301 alpha = CGFloat(rawValue & 0xff) / 255
2302 } else {
2303 red = CGFloat((rawValue >> 16) & 0xff) / 255
2304 green = CGFloat((rawValue >> 8) & 0xff) / 255
2305 blue = CGFloat(rawValue & 0xff) / 255
2306 alpha = 1
2307 }
2308
2309 self.init(red: red, green: green, blue: blue, alpha: alpha)
2310 }
2311}
apps/mobile/modules/t3-review-diff/package.json created+12
...@@ -0,0 +1,12 @@
1{
2 "name": "@t3tools/mobile-review-diff-native",
3 "version": "0.0.0",
4 "private": true,
5 "expo-module": {
6 "ios": {
7 "modules": [
8 "T3ReviewDiffModule"
9 ]
10 }
11 }
12}
apps/mobile/modules/t3-terminal/README.md created+38
...@@ -0,0 +1,38 @@
1# T3 Mobile Terminal Native Module
2
3This local Expo module owns the native terminal surface for the mobile app.
4
5The JavaScript contract is intentionally small:
6
7- input from the native surface is emitted as `{ data: string }`
8- resize from the native surface is emitted as `{ cols: number, rows: number }`
9- remote PTY output is delivered by the existing `WsRpcClient.terminal` RPC stream
10
11The iOS implementation uses the vendored `GhosttyKit.xcframework` built from the Ghostty custom-I/O
12fork, with T3's iOS 16 compatibility patch applied. `T3TerminalView` owns a `libghostty` surface and
13uses that callback I/O model:
14
151. initialize libghostty once for the process
162. create one Ghostty app and surface per native view
173. feed remote output into the surface with `ghostty_surface_feed_data`
184. send user input back to JS with the write callback
195. emit Ghostty's measured terminal size through `onResize`
20
21Android currently implements the same view name (`T3TerminalSurface`) and event payloads so the
22React Native screen and RPC code stay platform-neutral. The renderer backend can be replaced with a
23future Android Ghostty build without changing JS.
24
25Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`.
26
27## Rebuilding GhosttyKit
28
29The checked-in `GhosttyKit.xcframework` is built from the Ghostty custom-I/O fork (https://github.com/Yash-Singh1/ghostty/tree/custom-io).
30Set the directory to the cloned repository checked out on the `custom-io` branch to `GHOSTTY_SOURCE_DIR`.
31
32```bash
33apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh
34```
35
36The script builds Ghostty with Zig 0.15.2, strips the iOS archives, and replaces only the
37`ios-arm64` and `ios-arm64-simulator` slices. Xcode's Metal toolchain must be installed; if `metal`
38fails, run `xcodebuild -downloadComponent MetalToolchain`.
apps/mobile/modules/t3-terminal/T3TerminalNative.podspec created+21
...@@ -0,0 +1,21 @@
1require 'json'
2
3package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
5Pod::Spec.new do |s|
6 s.name = 'T3TerminalNative'
7 s.version = package['version']
8 s.summary = 'Native terminal surface for T3 Code mobile.'
9 s.description = 'Native terminal surface bridge used by the T3 Code React Native app.'
10 s.homepage = 'https://t3tools.com'
11 s.license = { :type => 'UNLICENSED' }
12 s.author = { 'T3 Tools' => 'hello@t3tools.com' }
13 s.platforms = { :ios => '16.1' }
14 s.source = { :path => '.' }
15 s.source_files = 'ios/**/*.{h,m,mm,swift}'
16 s.vendored_frameworks = 'Vendor/libghostty/GhosttyKit.xcframework'
17 s.frameworks = 'IOSurface', 'Metal', 'MetalKit', 'QuartzCore', 'UIKit'
18 s.libraries = 'c++', 'z'
19 s.swift_version = '5.9'
20 s.dependency 'ExpoModulesCore'
21end
apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md created+16
...@@ -0,0 +1,16 @@
1# Third-Party Notices
2
3## Ghostty / libghostty
4
5The iOS terminal renderer vendors `GhosttyKit.xcframework`, a libghostty build produced from T3's
6iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork.
7
8- Upstream project: https://github.com/ghostty-org/ghostty
9- Custom-I/O base fork: https://github.com/wiedymi/ghostty/tree/custom-io
10- Vendored source fork: https://github.com/Yash-Singh1/ghostty/tree/custom-io
11- Vendored revision: `d36c3b8dffd0d756dd5e5f4933962f774a0e6753`
12- Reference integration: https://github.com/vivy-company/vvterm
13- License: MIT
14
15Ghostty's MIT license applies to the vendored framework. Keep this notice in sync when updating
16`Vendor/libghostty`.
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/Info.plist created+47
...@@ -0,0 +1,47 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>AvailableLibraries</key>
6 <array>
7 <dict>
8 <key>BinaryPath</key>
9 <string>libghostty-fat.a</string>
10 <key>HeadersPath</key>
11 <string>Headers</string>
12 <key>LibraryIdentifier</key>
13 <string>ios-arm64</string>
14 <key>LibraryPath</key>
15 <string>libghostty-fat.a</string>
16 <key>SupportedArchitectures</key>
17 <array>
18 <string>arm64</string>
19 </array>
20 <key>SupportedPlatform</key>
21 <string>ios</string>
22 </dict>
23 <dict>
24 <key>BinaryPath</key>
25 <string>libghostty-fat.a</string>
26 <key>HeadersPath</key>
27 <string>Headers</string>
28 <key>LibraryIdentifier</key>
29 <string>ios-arm64-simulator</string>
30 <key>LibraryPath</key>
31 <string>libghostty-fat.a</string>
32 <key>SupportedArchitectures</key>
33 <array>
34 <string>arm64</string>
35 </array>
36 <key>SupportedPlatform</key>
37 <string>ios</string>
38 <key>SupportedPlatformVariant</key>
39 <string>simulator</string>
40 </dict>
41 </array>
42 <key>CFBundlePackageType</key>
43 <string>XFWK</string>
44 <key>XCFrameworkFormatVersion</key>
45 <string>1.0</string>
46</dict>
47</plist>
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h created+1218
...@@ -0,0 +1,1218 @@
1// Ghostty embedding API. The documentation for the embedding API is
2// only within the Zig source files that define the implementations. This
3// isn't meant to be a general purpose embedding API (yet) so there hasn't
4// been documentation or example work beyond that.
5//
6// The only consumer of this API is the macOS app, but the API is built to
7// be more general purpose.
8#ifndef GHOSTTY_H
9#define GHOSTTY_H
10
11#ifdef __cplusplus
12extern "C" {
13#endif
14
15#include <stdbool.h>
16#include <stddef.h>
17#include <stdint.h>
18
19#ifdef _MSC_VER
20#include <BaseTsd.h>
21typedef SSIZE_T ssize_t;
22#else
23#include <sys/types.h>
24#endif
25
26//-------------------------------------------------------------------
27// Macros
28
29#define GHOSTTY_SUCCESS 0
30
31// Symbol visibility for shared library builds. On Windows, functions
32// are exported from the DLL when building and imported when consuming.
33// On other platforms with GCC/Clang, functions are marked with default
34// visibility so they remain accessible when the library is built with
35// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC
36// before including this header to make this a no-op.
37#ifndef GHOSTTY_API
38#if defined(GHOSTTY_STATIC)
39 #define GHOSTTY_API
40#elif defined(_WIN32) || defined(_WIN64)
41 #ifdef GHOSTTY_BUILD_SHARED
42 #define GHOSTTY_API __declspec(dllexport)
43 #else
44 #define GHOSTTY_API __declspec(dllimport)
45 #endif
46#elif defined(__GNUC__) && __GNUC__ >= 4
47 #define GHOSTTY_API __attribute__((visibility("default")))
48#else
49 #define GHOSTTY_API
50#endif
51#endif
52
53//-------------------------------------------------------------------
54// Types
55
56// Opaque types
57typedef void* ghostty_app_t;
58typedef void* ghostty_config_t;
59typedef void* ghostty_surface_t;
60typedef void* ghostty_inspector_t;
61
62// All the types below are fully defined and must be kept in sync with
63// their Zig counterparts. Any changes to these types MUST have an associated
64// Zig change.
65typedef enum {
66 GHOSTTY_PLATFORM_INVALID,
67 GHOSTTY_PLATFORM_MACOS,
68 GHOSTTY_PLATFORM_IOS,
69} ghostty_platform_e;
70
71// Callback for custom I/O write handler.
72typedef void (*ghostty_surface_write_fn)(void* userdata,
73 const uint8_t* data,
74 size_t len);
75
76typedef enum {
77 GHOSTTY_CLIPBOARD_STANDARD,
78 GHOSTTY_CLIPBOARD_SELECTION,
79} ghostty_clipboard_e;
80
81typedef struct {
82 const char *mime;
83 const char *data;
84} ghostty_clipboard_content_s;
85
86typedef enum {
87 GHOSTTY_CLIPBOARD_REQUEST_PASTE,
88 GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
89 GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
90} ghostty_clipboard_request_e;
91
92typedef enum {
93 GHOSTTY_MOUSE_RELEASE,
94 GHOSTTY_MOUSE_PRESS,
95} ghostty_input_mouse_state_e;
96
97typedef enum {
98 GHOSTTY_MOUSE_UNKNOWN,
99 GHOSTTY_MOUSE_LEFT,
100 GHOSTTY_MOUSE_RIGHT,
101 GHOSTTY_MOUSE_MIDDLE,
102 GHOSTTY_MOUSE_FOUR,
103 GHOSTTY_MOUSE_FIVE,
104 GHOSTTY_MOUSE_SIX,
105 GHOSTTY_MOUSE_SEVEN,
106 GHOSTTY_MOUSE_EIGHT,
107 GHOSTTY_MOUSE_NINE,
108 GHOSTTY_MOUSE_TEN,
109 GHOSTTY_MOUSE_ELEVEN,
110} ghostty_input_mouse_button_e;
111
112typedef enum {
113 GHOSTTY_MOUSE_MOMENTUM_NONE,
114 GHOSTTY_MOUSE_MOMENTUM_BEGAN,
115 GHOSTTY_MOUSE_MOMENTUM_STATIONARY,
116 GHOSTTY_MOUSE_MOMENTUM_CHANGED,
117 GHOSTTY_MOUSE_MOMENTUM_ENDED,
118 GHOSTTY_MOUSE_MOMENTUM_CANCELLED,
119 GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN,
120} ghostty_input_mouse_momentum_e;
121
122typedef enum {
123 GHOSTTY_COLOR_SCHEME_LIGHT = 0,
124 GHOSTTY_COLOR_SCHEME_DARK = 1,
125} ghostty_color_scheme_e;
126
127// This is a packed struct (see src/input/mouse.zig) but the C standard
128// afaik doesn't let us reliably define packed structs so we build it up
129// from scratch.
130typedef int ghostty_input_scroll_mods_t;
131
132typedef enum {
133 GHOSTTY_MODS_NONE = 0,
134 GHOSTTY_MODS_SHIFT = 1 << 0,
135 GHOSTTY_MODS_CTRL = 1 << 1,
136 GHOSTTY_MODS_ALT = 1 << 2,
137 GHOSTTY_MODS_SUPER = 1 << 3,
138 GHOSTTY_MODS_CAPS = 1 << 4,
139 GHOSTTY_MODS_NUM = 1 << 5,
140 GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6,
141 GHOSTTY_MODS_CTRL_RIGHT = 1 << 7,
142 GHOSTTY_MODS_ALT_RIGHT = 1 << 8,
143 GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,
144} ghostty_input_mods_e;
145
146typedef enum {
147 GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,
148 GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,
149 GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,
150 GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,
151} ghostty_binding_flags_e;
152
153typedef enum {
154 GHOSTTY_ACTION_RELEASE,
155 GHOSTTY_ACTION_PRESS,
156 GHOSTTY_ACTION_REPEAT,
157} ghostty_input_action_e;
158
159// Based on: https://www.w3.org/TR/uievents-code/
160typedef enum {
161 GHOSTTY_KEY_UNIDENTIFIED,
162
163 // "Writing System Keys" § 3.1.1
164 GHOSTTY_KEY_BACKQUOTE,
165 GHOSTTY_KEY_BACKSLASH,
166 GHOSTTY_KEY_BRACKET_LEFT,
167 GHOSTTY_KEY_BRACKET_RIGHT,
168 GHOSTTY_KEY_COMMA,
169 GHOSTTY_KEY_DIGIT_0,
170 GHOSTTY_KEY_DIGIT_1,
171 GHOSTTY_KEY_DIGIT_2,
172 GHOSTTY_KEY_DIGIT_3,
173 GHOSTTY_KEY_DIGIT_4,
174 GHOSTTY_KEY_DIGIT_5,
175 GHOSTTY_KEY_DIGIT_6,
176 GHOSTTY_KEY_DIGIT_7,
177 GHOSTTY_KEY_DIGIT_8,
178 GHOSTTY_KEY_DIGIT_9,
179 GHOSTTY_KEY_EQUAL,
180 GHOSTTY_KEY_INTL_BACKSLASH,
181 GHOSTTY_KEY_INTL_RO,
182 GHOSTTY_KEY_INTL_YEN,
183 GHOSTTY_KEY_A,
184 GHOSTTY_KEY_B,
185 GHOSTTY_KEY_C,
186 GHOSTTY_KEY_D,
187 GHOSTTY_KEY_E,
188 GHOSTTY_KEY_F,
189 GHOSTTY_KEY_G,
190 GHOSTTY_KEY_H,
191 GHOSTTY_KEY_I,
192 GHOSTTY_KEY_J,
193 GHOSTTY_KEY_K,
194 GHOSTTY_KEY_L,
195 GHOSTTY_KEY_M,
196 GHOSTTY_KEY_N,
197 GHOSTTY_KEY_O,
198 GHOSTTY_KEY_P,
199 GHOSTTY_KEY_Q,
200 GHOSTTY_KEY_R,
201 GHOSTTY_KEY_S,
202 GHOSTTY_KEY_T,
203 GHOSTTY_KEY_U,
204 GHOSTTY_KEY_V,
205 GHOSTTY_KEY_W,
206 GHOSTTY_KEY_X,
207 GHOSTTY_KEY_Y,
208 GHOSTTY_KEY_Z,
209 GHOSTTY_KEY_MINUS,
210 GHOSTTY_KEY_PERIOD,
211 GHOSTTY_KEY_QUOTE,
212 GHOSTTY_KEY_SEMICOLON,
213 GHOSTTY_KEY_SLASH,
214
215 // "Functional Keys" § 3.1.2
216 GHOSTTY_KEY_ALT_LEFT,
217 GHOSTTY_KEY_ALT_RIGHT,
218 GHOSTTY_KEY_BACKSPACE,
219 GHOSTTY_KEY_CAPS_LOCK,
220 GHOSTTY_KEY_CONTEXT_MENU,
221 GHOSTTY_KEY_CONTROL_LEFT,
222 GHOSTTY_KEY_CONTROL_RIGHT,
223 GHOSTTY_KEY_ENTER,
224 GHOSTTY_KEY_META_LEFT,
225 GHOSTTY_KEY_META_RIGHT,
226 GHOSTTY_KEY_SHIFT_LEFT,
227 GHOSTTY_KEY_SHIFT_RIGHT,
228 GHOSTTY_KEY_SPACE,
229 GHOSTTY_KEY_TAB,
230 GHOSTTY_KEY_CONVERT,
231 GHOSTTY_KEY_KANA_MODE,
232 GHOSTTY_KEY_NON_CONVERT,
233
234 // "Control Pad Section" § 3.2
235 GHOSTTY_KEY_DELETE,
236 GHOSTTY_KEY_END,
237 GHOSTTY_KEY_HELP,
238 GHOSTTY_KEY_HOME,
239 GHOSTTY_KEY_INSERT,
240 GHOSTTY_KEY_PAGE_DOWN,
241 GHOSTTY_KEY_PAGE_UP,
242
243 // "Arrow Pad Section" § 3.3
244 GHOSTTY_KEY_ARROW_DOWN,
245 GHOSTTY_KEY_ARROW_LEFT,
246 GHOSTTY_KEY_ARROW_RIGHT,
247 GHOSTTY_KEY_ARROW_UP,
248
249 // "Numpad Section" § 3.4
250 GHOSTTY_KEY_NUM_LOCK,
251 GHOSTTY_KEY_NUMPAD_0,
252 GHOSTTY_KEY_NUMPAD_1,
253 GHOSTTY_KEY_NUMPAD_2,
254 GHOSTTY_KEY_NUMPAD_3,
255 GHOSTTY_KEY_NUMPAD_4,
256 GHOSTTY_KEY_NUMPAD_5,
257 GHOSTTY_KEY_NUMPAD_6,
258 GHOSTTY_KEY_NUMPAD_7,
259 GHOSTTY_KEY_NUMPAD_8,
260 GHOSTTY_KEY_NUMPAD_9,
261 GHOSTTY_KEY_NUMPAD_ADD,
262 GHOSTTY_KEY_NUMPAD_BACKSPACE,
263 GHOSTTY_KEY_NUMPAD_CLEAR,
264 GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
265 GHOSTTY_KEY_NUMPAD_COMMA,
266 GHOSTTY_KEY_NUMPAD_DECIMAL,
267 GHOSTTY_KEY_NUMPAD_DIVIDE,
268 GHOSTTY_KEY_NUMPAD_ENTER,
269 GHOSTTY_KEY_NUMPAD_EQUAL,
270 GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
271 GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
272 GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
273 GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
274 GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
275 GHOSTTY_KEY_NUMPAD_MULTIPLY,
276 GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
277 GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
278 GHOSTTY_KEY_NUMPAD_SUBTRACT,
279 GHOSTTY_KEY_NUMPAD_SEPARATOR,
280 GHOSTTY_KEY_NUMPAD_UP,
281 GHOSTTY_KEY_NUMPAD_DOWN,
282 GHOSTTY_KEY_NUMPAD_RIGHT,
283 GHOSTTY_KEY_NUMPAD_LEFT,
284 GHOSTTY_KEY_NUMPAD_BEGIN,
285 GHOSTTY_KEY_NUMPAD_HOME,
286 GHOSTTY_KEY_NUMPAD_END,
287 GHOSTTY_KEY_NUMPAD_INSERT,
288 GHOSTTY_KEY_NUMPAD_DELETE,
289 GHOSTTY_KEY_NUMPAD_PAGE_UP,
290 GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
291
292 // "Function Section" § 3.5
293 GHOSTTY_KEY_ESCAPE,
294 GHOSTTY_KEY_F1,
295 GHOSTTY_KEY_F2,
296 GHOSTTY_KEY_F3,
297 GHOSTTY_KEY_F4,
298 GHOSTTY_KEY_F5,
299 GHOSTTY_KEY_F6,
300 GHOSTTY_KEY_F7,
301 GHOSTTY_KEY_F8,
302 GHOSTTY_KEY_F9,
303 GHOSTTY_KEY_F10,
304 GHOSTTY_KEY_F11,
305 GHOSTTY_KEY_F12,
306 GHOSTTY_KEY_F13,
307 GHOSTTY_KEY_F14,
308 GHOSTTY_KEY_F15,
309 GHOSTTY_KEY_F16,
310 GHOSTTY_KEY_F17,
311 GHOSTTY_KEY_F18,
312 GHOSTTY_KEY_F19,
313 GHOSTTY_KEY_F20,
314 GHOSTTY_KEY_F21,
315 GHOSTTY_KEY_F22,
316 GHOSTTY_KEY_F23,
317 GHOSTTY_KEY_F24,
318 GHOSTTY_KEY_F25,
319 GHOSTTY_KEY_FN,
320 GHOSTTY_KEY_FN_LOCK,
321 GHOSTTY_KEY_PRINT_SCREEN,
322 GHOSTTY_KEY_SCROLL_LOCK,
323 GHOSTTY_KEY_PAUSE,
324
325 // "Media Keys" § 3.6
326 GHOSTTY_KEY_BROWSER_BACK,
327 GHOSTTY_KEY_BROWSER_FAVORITES,
328 GHOSTTY_KEY_BROWSER_FORWARD,
329 GHOSTTY_KEY_BROWSER_HOME,
330 GHOSTTY_KEY_BROWSER_REFRESH,
331 GHOSTTY_KEY_BROWSER_SEARCH,
332 GHOSTTY_KEY_BROWSER_STOP,
333 GHOSTTY_KEY_EJECT,
334 GHOSTTY_KEY_LAUNCH_APP_1,
335 GHOSTTY_KEY_LAUNCH_APP_2,
336 GHOSTTY_KEY_LAUNCH_MAIL,
337 GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
338 GHOSTTY_KEY_MEDIA_SELECT,
339 GHOSTTY_KEY_MEDIA_STOP,
340 GHOSTTY_KEY_MEDIA_TRACK_NEXT,
341 GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
342 GHOSTTY_KEY_POWER,
343 GHOSTTY_KEY_SLEEP,
344 GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
345 GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
346 GHOSTTY_KEY_AUDIO_VOLUME_UP,
347 GHOSTTY_KEY_WAKE_UP,
348
349 // "Legacy, Non-standard, and Special Keys" § 3.7
350 GHOSTTY_KEY_COPY,
351 GHOSTTY_KEY_CUT,
352 GHOSTTY_KEY_PASTE,
353} ghostty_input_key_e;
354
355typedef struct {
356 ghostty_input_action_e action;
357 ghostty_input_mods_e mods;
358 ghostty_input_mods_e consumed_mods;
359 uint32_t keycode;
360 const char* text;
361 uint32_t unshifted_codepoint;
362 bool composing;
363} ghostty_input_key_s;
364
365typedef enum {
366 GHOSTTY_TRIGGER_PHYSICAL,
367 GHOSTTY_TRIGGER_UNICODE,
368 GHOSTTY_TRIGGER_CATCH_ALL,
369} ghostty_input_trigger_tag_e;
370
371typedef union {
372 ghostty_input_key_e translated;
373 ghostty_input_key_e physical;
374 uint32_t unicode;
375 // catch_all has no payload
376} ghostty_input_trigger_key_u;
377
378typedef struct {
379 ghostty_input_trigger_tag_e tag;
380 ghostty_input_trigger_key_u key;
381 ghostty_input_mods_e mods;
382} ghostty_input_trigger_s;
383
384typedef struct {
385 const char* action_key;
386 const char* action;
387 const char* title;
388 const char* description;
389} ghostty_command_s;
390
391typedef enum {
392 GHOSTTY_BUILD_MODE_DEBUG,
393 GHOSTTY_BUILD_MODE_RELEASE_SAFE,
394 GHOSTTY_BUILD_MODE_RELEASE_FAST,
395 GHOSTTY_BUILD_MODE_RELEASE_SMALL,
396} ghostty_build_mode_e;
397
398typedef struct {
399 ghostty_build_mode_e build_mode;
400 const char* version;
401 uintptr_t version_len;
402} ghostty_info_s;
403
404typedef struct {
405 const char* message;
406} ghostty_diagnostic_s;
407
408typedef struct {
409 const char* ptr;
410 uintptr_t len;
411 bool sentinel;
412} ghostty_string_s;
413
414typedef struct {
415 double tl_px_x;
416 double tl_px_y;
417 uint32_t offset_start;
418 uint32_t offset_len;
419 const char* text;
420 uintptr_t text_len;
421} ghostty_text_s;
422
423typedef enum {
424 GHOSTTY_POINT_ACTIVE,
425 GHOSTTY_POINT_VIEWPORT,
426 GHOSTTY_POINT_SCREEN,
427 GHOSTTY_POINT_SURFACE,
428} ghostty_point_tag_e;
429
430typedef enum {
431 GHOSTTY_POINT_COORD_EXACT,
432 GHOSTTY_POINT_COORD_TOP_LEFT,
433 GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
434} ghostty_point_coord_e;
435
436typedef struct {
437 ghostty_point_tag_e tag;
438 ghostty_point_coord_e coord;
439 uint32_t x;
440 uint32_t y;
441} ghostty_point_s;
442
443typedef struct {
444 ghostty_point_s top_left;
445 ghostty_point_s bottom_right;
446 bool rectangle;
447} ghostty_selection_s;
448
449typedef struct {
450 const char* key;
451 const char* value;
452} ghostty_env_var_s;
453
454typedef struct {
455 void* nsview;
456} ghostty_platform_macos_s;
457
458typedef struct {
459 void* uiview;
460} ghostty_platform_ios_s;
461
462typedef union {
463 ghostty_platform_macos_s macos;
464 ghostty_platform_ios_s ios;
465} ghostty_platform_u;
466
467typedef enum {
468 GHOSTTY_SURFACE_CONTEXT_WINDOW = 0,
469 GHOSTTY_SURFACE_CONTEXT_TAB = 1,
470 GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
471} ghostty_surface_context_e;
472
473typedef struct {
474 ghostty_platform_e platform_tag;
475 ghostty_platform_u platform;
476 void* userdata;
477 double scale_factor;
478 float font_size;
479 const char* working_directory;
480 const char* command;
481 ghostty_env_var_s* env_vars;
482 size_t env_var_count;
483 const char* initial_input;
484 bool wait_after_command;
485 ghostty_surface_context_e context;
486 bool use_custom_io;
487} ghostty_surface_config_s;
488
489typedef struct {
490 uint16_t columns;
491 uint16_t rows;
492 uint32_t width_px;
493 uint32_t height_px;
494 uint32_t cell_width_px;
495 uint32_t cell_height_px;
496} ghostty_surface_size_s;
497
498// Config types
499
500// config.Path
501typedef struct {
502 const char* path;
503 bool optional;
504} ghostty_config_path_s;
505
506// config.Color
507typedef struct {
508 uint8_t r;
509 uint8_t g;
510 uint8_t b;
511} ghostty_config_color_s;
512
513// config.ColorList
514typedef struct {
515 const ghostty_config_color_s* colors;
516 size_t len;
517} ghostty_config_color_list_s;
518
519// config.RepeatableCommand
520typedef struct {
521 const ghostty_command_s* commands;
522 size_t len;
523} ghostty_config_command_list_s;
524
525// config.Palette
526typedef struct {
527 ghostty_config_color_s colors[256];
528} ghostty_config_palette_s;
529
530// config.QuickTerminalSize
531typedef enum {
532 GHOSTTY_QUICK_TERMINAL_SIZE_NONE,
533 GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE,
534 GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS,
535} ghostty_quick_terminal_size_tag_e;
536
537typedef union {
538 float percentage;
539 uint32_t pixels;
540} ghostty_quick_terminal_size_value_u;
541
542typedef struct {
543 ghostty_quick_terminal_size_tag_e tag;
544 ghostty_quick_terminal_size_value_u value;
545} ghostty_quick_terminal_size_s;
546
547typedef struct {
548 ghostty_quick_terminal_size_s primary;
549 ghostty_quick_terminal_size_s secondary;
550} ghostty_config_quick_terminal_size_s;
551
552// config.Fullscreen
553typedef enum {
554 GHOSTTY_CONFIG_FULLSCREEN_FALSE,
555 GHOSTTY_CONFIG_FULLSCREEN_TRUE,
556 GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE,
557 GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU,
558 GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH,
559} ghostty_config_fullscreen_e;
560
561// apprt.Target.Key
562typedef enum {
563 GHOSTTY_TARGET_APP,
564 GHOSTTY_TARGET_SURFACE,
565} ghostty_target_tag_e;
566
567typedef union {
568 ghostty_surface_t surface;
569} ghostty_target_u;
570
571typedef struct {
572 ghostty_target_tag_e tag;
573 ghostty_target_u target;
574} ghostty_target_s;
575
576// apprt.action.SplitDirection
577typedef enum {
578 GHOSTTY_SPLIT_DIRECTION_RIGHT,
579 GHOSTTY_SPLIT_DIRECTION_DOWN,
580 GHOSTTY_SPLIT_DIRECTION_LEFT,
581 GHOSTTY_SPLIT_DIRECTION_UP,
582} ghostty_action_split_direction_e;
583
584// apprt.action.GotoSplit
585typedef enum {
586 GHOSTTY_GOTO_SPLIT_PREVIOUS,
587 GHOSTTY_GOTO_SPLIT_NEXT,
588 GHOSTTY_GOTO_SPLIT_UP,
589 GHOSTTY_GOTO_SPLIT_LEFT,
590 GHOSTTY_GOTO_SPLIT_DOWN,
591 GHOSTTY_GOTO_SPLIT_RIGHT,
592} ghostty_action_goto_split_e;
593
594// apprt.action.GotoWindow
595typedef enum {
596 GHOSTTY_GOTO_WINDOW_PREVIOUS,
597 GHOSTTY_GOTO_WINDOW_NEXT,
598} ghostty_action_goto_window_e;
599
600// apprt.action.ResizeSplit.Direction
601typedef enum {
602 GHOSTTY_RESIZE_SPLIT_UP,
603 GHOSTTY_RESIZE_SPLIT_DOWN,
604 GHOSTTY_RESIZE_SPLIT_LEFT,
605 GHOSTTY_RESIZE_SPLIT_RIGHT,
606} ghostty_action_resize_split_direction_e;
607
608// apprt.action.ResizeSplit
609typedef struct {
610 uint16_t amount;
611 ghostty_action_resize_split_direction_e direction;
612} ghostty_action_resize_split_s;
613
614// apprt.action.MoveTab
615typedef struct {
616 ssize_t amount;
617} ghostty_action_move_tab_s;
618
619// apprt.action.GotoTab
620typedef enum {
621 GHOSTTY_GOTO_TAB_PREVIOUS = -1,
622 GHOSTTY_GOTO_TAB_NEXT = -2,
623 GHOSTTY_GOTO_TAB_LAST = -3,
624} ghostty_action_goto_tab_e;
625
626// apprt.action.Fullscreen
627typedef enum {
628 GHOSTTY_FULLSCREEN_NATIVE,
629 GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE,
630 GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU,
631 GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH,
632} ghostty_action_fullscreen_e;
633
634// apprt.action.FloatWindow
635typedef enum {
636 GHOSTTY_FLOAT_WINDOW_ON,
637 GHOSTTY_FLOAT_WINDOW_OFF,
638 GHOSTTY_FLOAT_WINDOW_TOGGLE,
639} ghostty_action_float_window_e;
640
641// apprt.action.SecureInput
642typedef enum {
643 GHOSTTY_SECURE_INPUT_ON,
644 GHOSTTY_SECURE_INPUT_OFF,
645 GHOSTTY_SECURE_INPUT_TOGGLE,
646} ghostty_action_secure_input_e;
647
648// apprt.action.Inspector
649typedef enum {
650 GHOSTTY_INSPECTOR_TOGGLE,
651 GHOSTTY_INSPECTOR_SHOW,
652 GHOSTTY_INSPECTOR_HIDE,
653} ghostty_action_inspector_e;
654
655// apprt.action.QuitTimer
656typedef enum {
657 GHOSTTY_QUIT_TIMER_START,
658 GHOSTTY_QUIT_TIMER_STOP,
659} ghostty_action_quit_timer_e;
660
661// apprt.action.Readonly
662typedef enum {
663 GHOSTTY_READONLY_OFF,
664 GHOSTTY_READONLY_ON,
665} ghostty_action_readonly_e;
666
667// apprt.action.DesktopNotification.C
668typedef struct {
669 const char* title;
670 const char* body;
671} ghostty_action_desktop_notification_s;
672
673// apprt.action.SetTitle.C
674typedef struct {
675 const char* title;
676} ghostty_action_set_title_s;
677
678// apprt.action.PromptTitle
679typedef enum {
680 GHOSTTY_PROMPT_TITLE_SURFACE,
681 GHOSTTY_PROMPT_TITLE_TAB,
682} ghostty_action_prompt_title_e;
683
684// apprt.action.Pwd.C
685typedef struct {
686 const char* pwd;
687} ghostty_action_pwd_s;
688
689// terminal.MouseShape
690typedef enum {
691 GHOSTTY_MOUSE_SHAPE_DEFAULT,
692 GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,
693 GHOSTTY_MOUSE_SHAPE_HELP,
694 GHOSTTY_MOUSE_SHAPE_POINTER,
695 GHOSTTY_MOUSE_SHAPE_PROGRESS,
696 GHOSTTY_MOUSE_SHAPE_WAIT,
697 GHOSTTY_MOUSE_SHAPE_CELL,
698 GHOSTTY_MOUSE_SHAPE_CROSSHAIR,
699 GHOSTTY_MOUSE_SHAPE_TEXT,
700 GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,
701 GHOSTTY_MOUSE_SHAPE_ALIAS,
702 GHOSTTY_MOUSE_SHAPE_COPY,
703 GHOSTTY_MOUSE_SHAPE_MOVE,
704 GHOSTTY_MOUSE_SHAPE_NO_DROP,
705 GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,
706 GHOSTTY_MOUSE_SHAPE_GRAB,
707 GHOSTTY_MOUSE_SHAPE_GRABBING,
708 GHOSTTY_MOUSE_SHAPE_ALL_SCROLL,
709 GHOSTTY_MOUSE_SHAPE_COL_RESIZE,
710 GHOSTTY_MOUSE_SHAPE_ROW_RESIZE,
711 GHOSTTY_MOUSE_SHAPE_N_RESIZE,
712 GHOSTTY_MOUSE_SHAPE_E_RESIZE,
713 GHOSTTY_MOUSE_SHAPE_S_RESIZE,
714 GHOSTTY_MOUSE_SHAPE_W_RESIZE,
715 GHOSTTY_MOUSE_SHAPE_NE_RESIZE,
716 GHOSTTY_MOUSE_SHAPE_NW_RESIZE,
717 GHOSTTY_MOUSE_SHAPE_SE_RESIZE,
718 GHOSTTY_MOUSE_SHAPE_SW_RESIZE,
719 GHOSTTY_MOUSE_SHAPE_EW_RESIZE,
720 GHOSTTY_MOUSE_SHAPE_NS_RESIZE,
721 GHOSTTY_MOUSE_SHAPE_NESW_RESIZE,
722 GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE,
723 GHOSTTY_MOUSE_SHAPE_ZOOM_IN,
724 GHOSTTY_MOUSE_SHAPE_ZOOM_OUT,
725} ghostty_action_mouse_shape_e;
726
727// apprt.action.MouseVisibility
728typedef enum {
729 GHOSTTY_MOUSE_VISIBLE,
730 GHOSTTY_MOUSE_HIDDEN,
731} ghostty_action_mouse_visibility_e;
732
733// apprt.action.MouseOverLink
734typedef struct {
735 const char* url;
736 size_t len;
737} ghostty_action_mouse_over_link_s;
738
739// apprt.action.SizeLimit
740typedef struct {
741 uint32_t min_width;
742 uint32_t min_height;
743 uint32_t max_width;
744 uint32_t max_height;
745} ghostty_action_size_limit_s;
746
747// apprt.action.InitialSize
748typedef struct {
749 uint32_t width;
750 uint32_t height;
751} ghostty_action_initial_size_s;
752
753// apprt.action.CellSize
754typedef struct {
755 uint32_t width;
756 uint32_t height;
757} ghostty_action_cell_size_s;
758
759// renderer.Health
760typedef enum {
761 GHOSTTY_RENDERER_HEALTH_HEALTHY,
762 GHOSTTY_RENDERER_HEALTH_UNHEALTHY,
763} ghostty_action_renderer_health_e;
764
765// apprt.action.KeySequence
766typedef struct {
767 bool active;
768 ghostty_input_trigger_s trigger;
769} ghostty_action_key_sequence_s;
770
771// apprt.action.KeyTable.Tag
772typedef enum {
773 GHOSTTY_KEY_TABLE_ACTIVATE,
774 GHOSTTY_KEY_TABLE_DEACTIVATE,
775 GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
776} ghostty_action_key_table_tag_e;
777
778// apprt.action.KeyTable.CValue
779typedef union {
780 struct {
781 const char *name;
782 size_t len;
783 } activate;
784} ghostty_action_key_table_u;
785
786// apprt.action.KeyTable.C
787typedef struct {
788 ghostty_action_key_table_tag_e tag;
789 ghostty_action_key_table_u value;
790} ghostty_action_key_table_s;
791
792// apprt.action.ColorKind
793typedef enum {
794 GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
795 GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2,
796 GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3,
797} ghostty_action_color_kind_e;
798
799// apprt.action.ColorChange
800typedef struct {
801 ghostty_action_color_kind_e kind;
802 uint8_t r;
803 uint8_t g;
804 uint8_t b;
805} ghostty_action_color_change_s;
806
807// apprt.action.ConfigChange
808typedef struct {
809 ghostty_config_t config;
810} ghostty_action_config_change_s;
811
812// apprt.action.ReloadConfig
813typedef struct {
814 bool soft;
815} ghostty_action_reload_config_s;
816
817// apprt.action.OpenUrlKind
818typedef enum {
819 GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,
820 GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
821 GHOSTTY_ACTION_OPEN_URL_KIND_HTML,
822} ghostty_action_open_url_kind_e;
823
824// apprt.action.OpenUrl.C
825typedef struct {
826 ghostty_action_open_url_kind_e kind;
827 const char* url;
828 uintptr_t len;
829} ghostty_action_open_url_s;
830
831// apprt.action.CloseTabMode
832typedef enum {
833 GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,
834 GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER,
835 GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT,
836} ghostty_action_close_tab_mode_e;
837
838// apprt.surface.Message.ChildExited
839typedef struct {
840 uint32_t exit_code;
841 uint64_t timetime_ms;
842} ghostty_surface_message_childexited_s;
843
844// terminal.osc.Command.ProgressReport.State
845typedef enum {
846 GHOSTTY_PROGRESS_STATE_REMOVE,
847 GHOSTTY_PROGRESS_STATE_SET,
848 GHOSTTY_PROGRESS_STATE_ERROR,
849 GHOSTTY_PROGRESS_STATE_INDETERMINATE,
850 GHOSTTY_PROGRESS_STATE_PAUSE,
851} ghostty_action_progress_report_state_e;
852
853// terminal.osc.Command.ProgressReport.C
854typedef struct {
855 ghostty_action_progress_report_state_e state;
856 // -1 if no progress was reported, otherwise 0-100 indicating percent
857 // completeness.
858 int8_t progress;
859} ghostty_action_progress_report_s;
860
861// apprt.action.CommandFinished.C
862typedef struct {
863 // -1 if no exit code was reported, otherwise 0-255
864 int16_t exit_code;
865 // number of nanoseconds that command was running for
866 uint64_t duration;
867} ghostty_action_command_finished_s;
868
869// apprt.action.StartSearch.C
870typedef struct {
871 const char* needle;
872} ghostty_action_start_search_s;
873
874// apprt.action.SearchTotal
875typedef struct {
876 ssize_t total;
877} ghostty_action_search_total_s;
878
879// apprt.action.SearchSelected
880typedef struct {
881 ssize_t selected;
882} ghostty_action_search_selected_s;
883
884// terminal.Scrollbar
885typedef struct {
886 uint64_t total;
887 uint64_t offset;
888 uint64_t len;
889} ghostty_action_scrollbar_s;
890
891// apprt.Action.Key
892typedef enum {
893 GHOSTTY_ACTION_QUIT,
894 GHOSTTY_ACTION_NEW_WINDOW,
895 GHOSTTY_ACTION_NEW_TAB,
896 GHOSTTY_ACTION_CLOSE_TAB,
897 GHOSTTY_ACTION_NEW_SPLIT,
898 GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
899 GHOSTTY_ACTION_TOGGLE_MAXIMIZE,
900 GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
901 GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
902 GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,
903 GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
904 GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,
905 GHOSTTY_ACTION_TOGGLE_VISIBILITY,
906 GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY,
907 GHOSTTY_ACTION_MOVE_TAB,
908 GHOSTTY_ACTION_GOTO_TAB,
909 GHOSTTY_ACTION_GOTO_SPLIT,
910 GHOSTTY_ACTION_GOTO_WINDOW,
911 GHOSTTY_ACTION_RESIZE_SPLIT,
912 GHOSTTY_ACTION_EQUALIZE_SPLITS,
913 GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM,
914 GHOSTTY_ACTION_PRESENT_TERMINAL,
915 GHOSTTY_ACTION_SIZE_LIMIT,
916 GHOSTTY_ACTION_RESET_WINDOW_SIZE,
917 GHOSTTY_ACTION_INITIAL_SIZE,
918 GHOSTTY_ACTION_CELL_SIZE,
919 GHOSTTY_ACTION_SCROLLBAR,
920 GHOSTTY_ACTION_RENDER,
921 GHOSTTY_ACTION_INSPECTOR,
922 GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
923 GHOSTTY_ACTION_RENDER_INSPECTOR,
924 GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
925 GHOSTTY_ACTION_SET_TITLE,
926 GHOSTTY_ACTION_SET_TAB_TITLE,
927 GHOSTTY_ACTION_PROMPT_TITLE,
928 GHOSTTY_ACTION_PWD,
929 GHOSTTY_ACTION_MOUSE_SHAPE,
930 GHOSTTY_ACTION_MOUSE_VISIBILITY,
931 GHOSTTY_ACTION_MOUSE_OVER_LINK,
932 GHOSTTY_ACTION_RENDERER_HEALTH,
933 GHOSTTY_ACTION_OPEN_CONFIG,
934 GHOSTTY_ACTION_QUIT_TIMER,
935 GHOSTTY_ACTION_FLOAT_WINDOW,
936 GHOSTTY_ACTION_SECURE_INPUT,
937 GHOSTTY_ACTION_KEY_SEQUENCE,
938 GHOSTTY_ACTION_KEY_TABLE,
939 GHOSTTY_ACTION_COLOR_CHANGE,
940 GHOSTTY_ACTION_RELOAD_CONFIG,
941 GHOSTTY_ACTION_CONFIG_CHANGE,
942 GHOSTTY_ACTION_CLOSE_WINDOW,
943 GHOSTTY_ACTION_RING_BELL,
944 GHOSTTY_ACTION_UNDO,
945 GHOSTTY_ACTION_REDO,
946 GHOSTTY_ACTION_CHECK_FOR_UPDATES,
947 GHOSTTY_ACTION_OPEN_URL,
948 GHOSTTY_ACTION_SHOW_CHILD_EXITED,
949 GHOSTTY_ACTION_PROGRESS_REPORT,
950 GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD,
951 GHOSTTY_ACTION_COMMAND_FINISHED,
952 GHOSTTY_ACTION_START_SEARCH,
953 GHOSTTY_ACTION_END_SEARCH,
954 GHOSTTY_ACTION_SEARCH_TOTAL,
955 GHOSTTY_ACTION_SEARCH_SELECTED,
956 GHOSTTY_ACTION_READONLY,
957 GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD,
958} ghostty_action_tag_e;
959
960typedef union {
961 ghostty_action_split_direction_e new_split;
962 ghostty_action_fullscreen_e toggle_fullscreen;
963 ghostty_action_move_tab_s move_tab;
964 ghostty_action_goto_tab_e goto_tab;
965 ghostty_action_goto_split_e goto_split;
966 ghostty_action_goto_window_e goto_window;
967 ghostty_action_resize_split_s resize_split;
968 ghostty_action_size_limit_s size_limit;
969 ghostty_action_initial_size_s initial_size;
970 ghostty_action_cell_size_s cell_size;
971 ghostty_action_scrollbar_s scrollbar;
972 ghostty_action_inspector_e inspector;
973 ghostty_action_desktop_notification_s desktop_notification;
974 ghostty_action_set_title_s set_title;
975 ghostty_action_set_title_s set_tab_title;
976 ghostty_action_prompt_title_e prompt_title;
977 ghostty_action_pwd_s pwd;
978 ghostty_action_mouse_shape_e mouse_shape;
979 ghostty_action_mouse_visibility_e mouse_visibility;
980 ghostty_action_mouse_over_link_s mouse_over_link;
981 ghostty_action_renderer_health_e renderer_health;
982 ghostty_action_quit_timer_e quit_timer;
983 ghostty_action_float_window_e float_window;
984 ghostty_action_secure_input_e secure_input;
985 ghostty_action_key_sequence_s key_sequence;
986 ghostty_action_key_table_s key_table;
987 ghostty_action_color_change_s color_change;
988 ghostty_action_reload_config_s reload_config;
989 ghostty_action_config_change_s config_change;
990 ghostty_action_open_url_s open_url;
991 ghostty_action_close_tab_mode_e close_tab_mode;
992 ghostty_surface_message_childexited_s child_exited;
993 ghostty_action_progress_report_s progress_report;
994 ghostty_action_command_finished_s command_finished;
995 ghostty_action_start_search_s start_search;
996 ghostty_action_search_total_s search_total;
997 ghostty_action_search_selected_s search_selected;
998 ghostty_action_readonly_e readonly;
999} ghostty_action_u;
1000
1001typedef struct {
1002 ghostty_action_tag_e tag;
1003 ghostty_action_u action;
1004} ghostty_action_s;
1005
1006typedef void (*ghostty_runtime_wakeup_cb)(void*);
1007typedef bool (*ghostty_runtime_read_clipboard_cb)(void*,
1008 ghostty_clipboard_e,
1009 void*);
1010typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
1011 void*,
1012 const char*,
1013 void*,
1014 ghostty_clipboard_request_e);
1015typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
1016 ghostty_clipboard_e,
1017 const ghostty_clipboard_content_s*,
1018 size_t,
1019 bool);
1020typedef void (*ghostty_runtime_close_surface_cb)(void*, bool);
1021typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t,
1022 ghostty_target_s,
1023 ghostty_action_s);
1024
1025typedef struct {
1026 void* userdata;
1027 bool supports_selection_clipboard;
1028 ghostty_runtime_wakeup_cb wakeup_cb;
1029 ghostty_runtime_action_cb action_cb;
1030 ghostty_runtime_read_clipboard_cb read_clipboard_cb;
1031 ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb;
1032 ghostty_runtime_write_clipboard_cb write_clipboard_cb;
1033 ghostty_runtime_close_surface_cb close_surface_cb;
1034} ghostty_runtime_config_s;
1035
1036// apprt.ipc.Target.Key
1037typedef enum {
1038 GHOSTTY_IPC_TARGET_CLASS,
1039 GHOSTTY_IPC_TARGET_DETECT,
1040} ghostty_ipc_target_tag_e;
1041
1042typedef union {
1043 char *klass;
1044} ghostty_ipc_target_u;
1045
1046typedef struct {
1047 ghostty_ipc_target_tag_e tag;
1048 ghostty_ipc_target_u target;
1049} chostty_ipc_target_s;
1050
1051// apprt.ipc.Action.NewWindow
1052typedef struct {
1053 // This should be a null terminated list of strings.
1054 const char **arguments;
1055} ghostty_ipc_action_new_window_s;
1056
1057typedef union {
1058 ghostty_ipc_action_new_window_s new_window;
1059} ghostty_ipc_action_u;
1060
1061// apprt.ipc.Action.Key
1062typedef enum {
1063 GHOSTTY_IPC_ACTION_NEW_WINDOW,
1064} ghostty_ipc_action_tag_e;
1065
1066//-------------------------------------------------------------------
1067// Published API
1068
1069GHOSTTY_API int ghostty_init(uintptr_t, char**);
1070GHOSTTY_API void ghostty_cli_try_action(void);
1071GHOSTTY_API ghostty_info_s ghostty_info(void);
1072GHOSTTY_API const char* ghostty_translate(const char*);
1073GHOSTTY_API void ghostty_string_free(ghostty_string_s);
1074
1075GHOSTTY_API ghostty_config_t ghostty_config_new();
1076GHOSTTY_API void ghostty_config_free(ghostty_config_t);
1077GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t);
1078GHOSTTY_API void ghostty_config_load_cli_args(ghostty_config_t);
1079GHOSTTY_API void ghostty_config_load_file(ghostty_config_t, const char*);
1080GHOSTTY_API void ghostty_config_load_default_files(ghostty_config_t);
1081GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t);
1082GHOSTTY_API void ghostty_config_finalize(ghostty_config_t);
1083GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t);
1084GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t,
1085 const char*,
1086 uintptr_t);
1087GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t);
1088GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t);
1089GHOSTTY_API ghostty_string_s ghostty_config_open_path(void);
1090
1091GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
1092 ghostty_config_t);
1093GHOSTTY_API void ghostty_app_free(ghostty_app_t);
1094GHOSTTY_API void ghostty_app_tick(ghostty_app_t);
1095GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t);
1096GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool);
1097GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
1098GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s);
1099GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t);
1100GHOSTTY_API void ghostty_app_open_config(ghostty_app_t);
1101GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t);
1102GHOSTTY_API bool ghostty_app_needs_confirm_quit(ghostty_app_t);
1103GHOSTTY_API bool ghostty_app_has_global_keybinds(ghostty_app_t);
1104GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);
1105
1106GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new();
1107
1108GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t,
1109 const ghostty_surface_config_s*);
1110GHOSTTY_API void ghostty_surface_free(ghostty_surface_t);
1111GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t);
1112GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t);
1113GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);
1114GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
1115GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
1116GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t);
1117GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t);
1118GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t);
1119GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t);
1120GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t,
1121 ghostty_surface_write_fn,
1122 void*);
1123GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
1124GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool);
1125GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool);
1126GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t);
1127GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t);
1128GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t);
1129GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t);
1130GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t,
1131 ghostty_color_scheme_e);
1132GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
1133 ghostty_input_mods_e);
1134GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
1135GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t,
1136 ghostty_input_key_s,
1137 ghostty_binding_flags_e*);
1138GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
1139GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
1140GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t);
1141GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t,
1142 ghostty_input_mouse_state_e,
1143 ghostty_input_mouse_button_e,
1144 ghostty_input_mods_e);
1145GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t,
1146 double,
1147 double,
1148 ghostty_input_mods_e);
1149GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t,
1150 double,
1151 double,
1152 ghostty_input_scroll_mods_t);
1153GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);
1154GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*);
1155GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t);
1156GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e);
1157GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t,
1158 ghostty_action_goto_split_e);
1159GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t,
1160 ghostty_action_resize_split_direction_e,
1161 uint16_t);
1162GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t);
1163GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t);
1164GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
1165 const char*,
1166 void*,
1167 bool);
1168GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t);
1169GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
1170GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t,
1171 ghostty_selection_s,
1172 ghostty_text_s*);
1173GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
1174
1175#ifdef __APPLE__
1176GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
1177GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t);
1178GHOSTTY_API bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*);
1179#endif
1180
1181GHOSTTY_API ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);
1182GHOSTTY_API void ghostty_inspector_free(ghostty_surface_t);
1183GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool);
1184GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double);
1185GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t);
1186GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t,
1187 ghostty_input_mouse_state_e,
1188 ghostty_input_mouse_button_e,
1189 ghostty_input_mods_e);
1190GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double);
1191GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t,
1192 double,
1193 double,
1194 ghostty_input_scroll_mods_t);
1195GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t,
1196 ghostty_input_action_e,
1197 ghostty_input_key_e,
1198 ghostty_input_mods_e);
1199GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*);
1200
1201#ifdef __APPLE__
1202GHOSTTY_API bool ghostty_inspector_metal_init(ghostty_inspector_t, void*);
1203GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*);
1204GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t);
1205#endif
1206
1207// APIs I'd like to get rid of eventually but are still needed for now.
1208// Don't use these unless you know what you're doing.
1209GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*);
1210
1211// Benchmark API, if available.
1212GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*);
1213
1214#ifdef __cplusplus
1215}
1216#endif
1217
1218#endif /* GHOSTTY_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h created+87
...@@ -0,0 +1,87 @@
1/**
2 * @file vt.h
3 *
4 * libghostty-vt - Virtual terminal emulator library
5 *
6 * This library provides functionality for parsing and handling terminal
7 * escape sequences as well as maintaining terminal state such as styles,
8 * cursor position, screen, scrollback, and more.
9 *
10 * WARNING: This is an incomplete, work-in-progress API. It is not yet
11 * stable and is definitely going to change.
12 */
13
14/**
15 * @mainpage libghostty-vt - Virtual Terminal Emulator Library
16 *
17 * libghostty-vt is a C library which implements a modern terminal emulator,
18 * extracted from the [Ghostty](https://ghostty.org) terminal emulator.
19 *
20 * libghostty-vt contains the logic for handling the core parts of a terminal
21 * emulator: parsing terminal escape sequences, maintaining terminal state,
22 * encoding input events, etc. It can handle scrollback, line wrapping,
23 * reflow on resize, and more.
24 *
25 * @warning This library is currently in development and the API is not yet stable.
26 * Breaking changes are expected in future versions. Use with caution in production code.
27 *
28 * @section groups_sec API Reference
29 *
30 * The API is organized into the following groups:
31 * - @ref key "Key Encoding" - Encode key events into terminal sequences
32 * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
33 * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
34 * - @ref paste "Paste Utilities" - Validate paste data safety
35 * - @ref allocator "Memory Management" - Memory management and custom allocators
36 * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions
37 *
38 * @section examples_sec Examples
39 *
40 * Complete working examples:
41 * - @ref c-vt/src/main.c - OSC parser example
42 * - @ref c-vt-key-encode/src/main.c - Key encoding example
43 * - @ref c-vt-paste/src/main.c - Paste safety check example
44 * - @ref c-vt-sgr/src/main.c - SGR parser example
45 *
46 */
47
48/** @example c-vt/src/main.c
49 * This example demonstrates how to use the OSC parser to parse an OSC sequence,
50 * extract command information, and retrieve command-specific data like window titles.
51 */
52
53/** @example c-vt-key-encode/src/main.c
54 * This example demonstrates how to use the key encoder to convert key events
55 * into terminal escape sequences using the Kitty keyboard protocol.
56 */
57
58/** @example c-vt-paste/src/main.c
59 * This example demonstrates how to use the paste utilities to check if
60 * paste data is safe before sending it to the terminal.
61 */
62
63/** @example c-vt-sgr/src/main.c
64 * This example demonstrates how to use the SGR parser to parse terminal
65 * styling sequences and extract text attributes like colors and underline styles.
66 */
67
68#ifndef GHOSTTY_VT_H
69#define GHOSTTY_VT_H
70
71#ifdef __cplusplus
72extern "C" {
73#endif
74
75#include <ghostty/vt/result.h>
76#include <ghostty/vt/allocator.h>
77#include <ghostty/vt/osc.h>
78#include <ghostty/vt/sgr.h>
79#include <ghostty/vt/key.h>
80#include <ghostty/vt/paste.h>
81#include <ghostty/vt/wasm.h>
82
83#ifdef __cplusplus
84}
85#endif
86
87#endif /* GHOSTTY_VT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h created+196
...@@ -0,0 +1,196 @@
1/**
2 * @file allocator.h
3 *
4 * Memory management interface for libghostty-vt.
5 */
6
7#ifndef GHOSTTY_VT_ALLOCATOR_H
8#define GHOSTTY_VT_ALLOCATOR_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13
14/** @defgroup allocator Memory Management
15 *
16 * libghostty-vt does require memory allocation for various operations,
17 * but is resilient to allocation failures and will gracefully handle
18 * out-of-memory situations by returning error codes.
19 *
20 * The exact memory management semantics are documented in the relevant
21 * functions and data structures.
22 *
23 * libghostty-vt uses explicit memory allocation via an allocator
24 * interface provided by GhosttyAllocator. The interface is based on the
25 * [Zig](https://ziglang.org) allocator interface, since this has been
26 * shown to be a flexible and powerful interface in practice and enables
27 * a wide variety of allocation strategies.
28 *
29 * **For the common case, you can pass NULL as the allocator for any
30 * function that accepts one,** and libghostty will use a default allocator.
31 * The default allocator will be libc malloc/free if libc is linked.
32 * Otherwise, a custom allocator is used (currently Zig's SMP allocator)
33 * that doesn't require any external dependencies.
34 *
35 * ## Basic Usage
36 *
37 * For simple use cases, you can ignore this interface entirely by passing NULL
38 * as the allocator parameter to functions that accept one. This will use the
39 * default allocator (typically libc malloc/free, if libc is linked, but
40 * we provide our own default allocator if libc isn't linked).
41 *
42 * To use a custom allocator:
43 * 1. Implement the GhosttyAllocatorVtable function pointers
44 * 2. Create a GhosttyAllocator struct with your vtable and context
45 * 3. Pass the allocator to functions that accept one
46 *
47 * @{
48 */
49
50/**
51 * Function table for custom memory allocator operations.
52 *
53 * This vtable defines the interface for a custom memory allocator. All
54 * function pointers must be valid and non-NULL.
55 *
56 * @ingroup allocator
57 *
58 * If you're not going to use a custom allocator, you can ignore all of
59 * this. All functions that take an allocator pointer allow NULL to use a
60 * default allocator.
61 *
62 * The interface is based on the Zig allocator interface. I'll say up front
63 * that it is easy to look at this interface and think "wow, this is really
64 * overcomplicated". The reason for this complexity is well thought out by
65 * the Zig folks, and it enables a diverse set of allocation strategies
66 * as shown by the Zig ecosystem. As a consolation, please note that many
67 * of the arguments are only needed for advanced use cases and can be
68 * safely ignored in simple implementations. For example, if you look at
69 * the Zig implementation of the libc allocator in `lib/std/heap.zig`
70 * (search for CAllocator), you'll see it is very simple.
71 *
72 * We chose to align with the Zig allocator interface because:
73 *
74 * 1. It is a proven interface that serves a wide variety of use cases
75 * in the real world via the Zig ecosystem. It's shown to work.
76 *
77 * 2. Our core implementation itself is Zig, and this lets us very
78 * cheaply and easily convert between C and Zig allocators.
79 *
80 * NOTE(mitchellh): In the future, we can have default implementations of
81 * resize/remap and allow those to be null.
82 */
83typedef struct {
84 /**
85 * Return a pointer to `len` bytes with specified `alignment`, or return
86 * `NULL` indicating the allocation failed.
87 *
88 * @param ctx The allocator context
89 * @param len Number of bytes to allocate
90 * @param alignment Required alignment for the allocation. Guaranteed to
91 * be a power of two between 1 and 16 inclusive.
92 * @param ret_addr First return address of the allocation call stack (0 if not provided)
93 * @return Pointer to allocated memory, or NULL if allocation failed
94 */
95 void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr);
96
97 /**
98 * Attempt to expand or shrink memory in place.
99 *
100 * `memory_len` must equal the length requested from the most recent
101 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
102 * equal the same value that was passed as the `alignment` parameter to
103 * the original `alloc` call.
104 *
105 * `new_len` must be greater than zero.
106 *
107 * @param ctx The allocator context
108 * @param memory Pointer to the memory block to resize
109 * @param memory_len Current size of the memory block
110 * @param alignment Alignment (must match original allocation)
111 * @param new_len New requested size
112 * @param ret_addr First return address of the allocation call stack (0 if not provided)
113 * @return true if resize was successful in-place, false if relocation would be required
114 */
115 bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
116
117 /**
118 * Attempt to expand or shrink memory, allowing relocation.
119 *
120 * `memory_len` must equal the length requested from the most recent
121 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
122 * equal the same value that was passed as the `alignment` parameter to
123 * the original `alloc` call.
124 *
125 * A non-`NULL` return value indicates the resize was successful. The
126 * allocation may have same address, or may have been relocated. In either
127 * case, the allocation now has size of `new_len`. A `NULL` return value
128 * indicates that the resize would be equivalent to allocating new memory,
129 * copying the bytes from the old memory, and then freeing the old memory.
130 * In such case, it is more efficient for the caller to perform the copy.
131 *
132 * `new_len` must be greater than zero.
133 *
134 * @param ctx The allocator context
135 * @param memory Pointer to the memory block to remap
136 * @param memory_len Current size of the memory block
137 * @param alignment Alignment (must match original allocation)
138 * @param new_len New requested size
139 * @param ret_addr First return address of the allocation call stack (0 if not provided)
140 * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed
141 */
142 void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
143
144 /**
145 * Free and invalidate a region of memory.
146 *
147 * `memory_len` must equal the length requested from the most recent
148 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
149 * equal the same value that was passed as the `alignment` parameter to
150 * the original `alloc` call.
151 *
152 * @param ctx The allocator context
153 * @param memory Pointer to the memory block to free
154 * @param memory_len Size of the memory block
155 * @param alignment Alignment (must match original allocation)
156 * @param ret_addr First return address of the allocation call stack (0 if not provided)
157 */
158 void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr);
159} GhosttyAllocatorVtable;
160
161/**
162 * Custom memory allocator.
163 *
164 * For functions that take an allocator pointer, a NULL pointer indicates
165 * that the default allocator should be used. The default allocator will
166 * be libc malloc/free if we're linking to libc. If libc isn't linked,
167 * a custom allocator is used (currently Zig's SMP allocator).
168 *
169 * @ingroup allocator
170 *
171 * Usage example:
172 * @code
173 * GhosttyAllocator allocator = {
174 * .vtable = &my_allocator_vtable,
175 * .ctx = my_allocator_state
176 * };
177 * @endcode
178 */
179typedef struct GhosttyAllocator {
180 /**
181 * Opaque context pointer passed to all vtable functions.
182 * This allows the allocator implementation to maintain state
183 * or reference external resources needed for memory management.
184 */
185 void *ctx;
186
187 /**
188 * Pointer to the allocator's vtable containing function pointers
189 * for memory operations (alloc, resize, remap, free).
190 */
191 const GhosttyAllocatorVtable *vtable;
192} GhosttyAllocator;
193
194/** @} */
195
196#endif /* GHOSTTY_VT_ALLOCATOR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h created+96
...@@ -0,0 +1,96 @@
1/**
2 * @file color.h
3 *
4 * Color types and utilities.
5 */
6
7#ifndef GHOSTTY_VT_COLOR_H
8#define GHOSTTY_VT_COLOR_H
9
10#include <stdint.h>
11
12#ifdef __cplusplus
13extern "C" {
14#endif
15
16/**
17 * RGB color value.
18 *
19 * @ingroup sgr
20 */
21typedef struct {
22 uint8_t r; /**< Red component (0-255) */
23 uint8_t g; /**< Green component (0-255) */
24 uint8_t b; /**< Blue component (0-255) */
25} GhosttyColorRgb;
26
27/**
28 * Palette color index (0-255).
29 *
30 * @ingroup sgr
31 */
32typedef uint8_t GhosttyColorPaletteIndex;
33
34/** @addtogroup sgr
35 * @{
36 */
37
38/** Black color (0) @ingroup sgr */
39#define GHOSTTY_COLOR_NAMED_BLACK 0
40/** Red color (1) @ingroup sgr */
41#define GHOSTTY_COLOR_NAMED_RED 1
42/** Green color (2) @ingroup sgr */
43#define GHOSTTY_COLOR_NAMED_GREEN 2
44/** Yellow color (3) @ingroup sgr */
45#define GHOSTTY_COLOR_NAMED_YELLOW 3
46/** Blue color (4) @ingroup sgr */
47#define GHOSTTY_COLOR_NAMED_BLUE 4
48/** Magenta color (5) @ingroup sgr */
49#define GHOSTTY_COLOR_NAMED_MAGENTA 5
50/** Cyan color (6) @ingroup sgr */
51#define GHOSTTY_COLOR_NAMED_CYAN 6
52/** White color (7) @ingroup sgr */
53#define GHOSTTY_COLOR_NAMED_WHITE 7
54/** Bright black color (8) @ingroup sgr */
55#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8
56/** Bright red color (9) @ingroup sgr */
57#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9
58/** Bright green color (10) @ingroup sgr */
59#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10
60/** Bright yellow color (11) @ingroup sgr */
61#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11
62/** Bright blue color (12) @ingroup sgr */
63#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12
64/** Bright magenta color (13) @ingroup sgr */
65#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13
66/** Bright cyan color (14) @ingroup sgr */
67#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14
68/** Bright white color (15) @ingroup sgr */
69#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15
70
71/** @} */
72
73/**
74 * Get the RGB color components.
75 *
76 * This function extracts the individual red, green, and blue components
77 * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments
78 * where accessing struct fields directly is difficult.
79 *
80 * @param color The RGB color value
81 * @param r Pointer to store the red component (0-255)
82 * @param g Pointer to store the green component (0-255)
83 * @param b Pointer to store the blue component (0-255)
84 *
85 * @ingroup sgr
86 */
87void ghostty_color_rgb_get(GhosttyColorRgb color,
88 uint8_t* r,
89 uint8_t* g,
90 uint8_t* b);
91
92#ifdef __cplusplus
93}
94#endif
95
96#endif /* GHOSTTY_VT_COLOR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h created+80
...@@ -0,0 +1,80 @@
1/**
2 * @file key.h
3 *
4 * Key encoding module - encode key events into terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_KEY_H
8#define GHOSTTY_VT_KEY_H
9
10/** @defgroup key Key Encoding
11 *
12 * Utilities for encoding key events into terminal escape sequences,
13 * supporting both legacy encoding as well as Kitty Keyboard Protocol.
14 *
15 * ## Basic Usage
16 *
17 * 1. Create an encoder instance with ghostty_key_encoder_new()
18 * 2. Configure encoder options with ghostty_key_encoder_setopt().
19 * 3. For each key event:
20 * - Create a key event with ghostty_key_event_new()
21 * - Set event properties (action, key, modifiers, etc.)
22 * - Encode with ghostty_key_encoder_encode()
23 * - Free the event with ghostty_key_event_free()
24 * - Note: You can also reuse the same key event multiple times by
25 * changing its properties.
26 * 4. Free the encoder with ghostty_key_encoder_free() when done
27 *
28 * ## Example
29 *
30 * @code{.c}
31 * #include <assert.h>
32 * #include <stdio.h>
33 * #include <ghostty/vt.h>
34 *
35 * int main() {
36 * // Create encoder
37 * GhosttyKeyEncoder encoder;
38 * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder);
39 * assert(result == GHOSTTY_SUCCESS);
40 *
41 * // Enable Kitty keyboard protocol with all features
42 * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS,
43 * &(uint8_t){GHOSTTY_KITTY_KEY_ALL});
44 *
45 * // Create and configure key event for Ctrl+C press
46 * GhosttyKeyEvent event;
47 * result = ghostty_key_event_new(NULL, &event);
48 * assert(result == GHOSTTY_SUCCESS);
49 * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS);
50 * ghostty_key_event_set_key(event, GHOSTTY_KEY_C);
51 * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL);
52 *
53 * // Encode the key event
54 * char buf[128];
55 * size_t written = 0;
56 * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
57 * assert(result == GHOSTTY_SUCCESS);
58 *
59 * // Use the encoded sequence (e.g., write to terminal)
60 * fwrite(buf, 1, written, stdout);
61 *
62 * // Cleanup
63 * ghostty_key_event_free(event);
64 * ghostty_key_encoder_free(encoder);
65 * return 0;
66 * }
67 * @endcode
68 *
69 * For a complete working example, see example/c-vt-key-encode in the
70 * repository.
71 *
72 * @{
73 */
74
75#include <ghostty/vt/key/event.h>
76#include <ghostty/vt/key/encoder.h>
77
78/** @} */
79
80#endif /* GHOSTTY_VT_KEY_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h created+221
...@@ -0,0 +1,221 @@
1/**
2 * @file encoder.h
3 *
4 * Key event encoding to terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_KEY_ENCODER_H
8#define GHOSTTY_VT_KEY_ENCODER_H
9
10#include <stddef.h>
11#include <stdint.h>
12#include <ghostty/vt/result.h>
13#include <ghostty/vt/allocator.h>
14#include <ghostty/vt/key/event.h>
15
16/**
17 * Opaque handle to a key encoder instance.
18 *
19 * This handle represents a key encoder that converts key events into terminal
20 * escape sequences.
21 *
22 * @ingroup key
23 */
24typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder;
25
26/**
27 * Kitty keyboard protocol flags.
28 *
29 * Bitflags representing the various modes of the Kitty keyboard protocol.
30 * These can be combined using bitwise OR operations. Valid values all
31 * start with `GHOSTTY_KITTY_KEY_`.
32 *
33 * @ingroup key
34 */
35typedef uint8_t GhosttyKittyKeyFlags;
36
37/** Kitty keyboard protocol disabled (all flags off) */
38#define GHOSTTY_KITTY_KEY_DISABLED 0
39
40/** Disambiguate escape codes */
41#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0)
42
43/** Report key press and release events */
44#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1)
45
46/** Report alternate key codes */
47#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2)
48
49/** Report all key events including those normally handled by the terminal */
50#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3)
51
52/** Report associated text with key events */
53#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4)
54
55/** All Kitty keyboard protocol flags enabled */
56#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED)
57
58/**
59 * macOS option key behavior.
60 *
61 * Determines whether the "option" key on macOS is treated as "alt" or not.
62 * See the Ghostty `macos-option-as-alt` configuration option for more details.
63 *
64 * @ingroup key
65 */
66typedef enum {
67 /** Option key is not treated as alt */
68 GHOSTTY_OPTION_AS_ALT_FALSE = 0,
69 /** Option key is treated as alt */
70 GHOSTTY_OPTION_AS_ALT_TRUE = 1,
71 /** Only left option key is treated as alt */
72 GHOSTTY_OPTION_AS_ALT_LEFT = 2,
73 /** Only right option key is treated as alt */
74 GHOSTTY_OPTION_AS_ALT_RIGHT = 3,
75} GhosttyOptionAsAlt;
76
77/**
78 * Key encoder option identifiers.
79 *
80 * These values are used with ghostty_key_encoder_setopt() to configure
81 * the behavior of the key encoder.
82 *
83 * @ingroup key
84 */
85typedef enum {
86 /** Terminal DEC mode 1: cursor key application mode (value: bool) */
87 GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0,
88
89 /** Terminal DEC mode 66: keypad key application mode (value: bool) */
90 GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1,
91
92 /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */
93 GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2,
94
95 /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */
96 GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3,
97
98 /** xterm modifyOtherKeys mode 2 (value: bool) */
99 GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4,
100
101 /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */
102 GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5,
103
104 /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */
105 GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6,
106} GhosttyKeyEncoderOption;
107
108/**
109 * Create a new key encoder instance.
110 *
111 * Creates a new key encoder with default options. The encoder can be configured
112 * using ghostty_key_encoder_setopt() and must be freed using
113 * ghostty_key_encoder_free() when no longer needed.
114 *
115 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
116 * @param encoder Pointer to store the created encoder handle
117 * @return GHOSTTY_SUCCESS on success, or an error code on failure
118 *
119 * @ingroup key
120 */
121GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder);
122
123/**
124 * Free a key encoder instance.
125 *
126 * Releases all resources associated with the key encoder. After this call,
127 * the encoder handle becomes invalid and must not be used.
128 *
129 * @param encoder The encoder handle to free (may be NULL)
130 *
131 * @ingroup key
132 */
133void ghostty_key_encoder_free(GhosttyKeyEncoder encoder);
134
135/**
136 * Set an option on the key encoder.
137 *
138 * Configures the behavior of the key encoder. Options control various aspects
139 * of encoding such as terminal modes (cursor key application mode, keypad mode),
140 * protocol selection (Kitty keyboard protocol flags), and platform-specific
141 * behaviors (macOS option-as-alt).
142 *
143 * A null pointer value does nothing. It does not reset the value to the
144 * default. The setopt call will do nothing.
145 *
146 * @param encoder The encoder handle, must not be NULL
147 * @param option The option to set
148 * @param value Pointer to the value to set (type depends on the option)
149 *
150 * @ingroup key
151 */
152void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value);
153
154/**
155 * Encode a key event into a terminal escape sequence.
156 *
157 * Converts a key event into the appropriate terminal escape sequence based on
158 * the encoder's current options. The sequence is written to the provided buffer.
159 *
160 * Not all key events produce output. For example, unmodified modifier keys
161 * typically don't generate escape sequences. Check the out_len parameter to
162 * determine if any data was written.
163 *
164 * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY
165 * and out_len will contain the required buffer size. The caller can then
166 * allocate a larger buffer and call the function again.
167 *
168 * @param encoder The encoder handle, must not be NULL
169 * @param event The key event to encode, must not be NULL
170 * @param out_buf Buffer to write the encoded sequence to
171 * @param out_buf_size Size of the output buffer in bytes
172 * @param out_len Pointer to store the number of bytes written (may be NULL)
173 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code
174 *
175 * ## Example: Calculate required buffer size
176 *
177 * @code{.c}
178 * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY)
179 * size_t required = 0;
180 * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required);
181 * assert(result == GHOSTTY_OUT_OF_MEMORY);
182 *
183 * // Allocate buffer of required size
184 * char *buf = malloc(required);
185 *
186 * // Encode with properly sized buffer
187 * size_t written = 0;
188 * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written);
189 * assert(result == GHOSTTY_SUCCESS);
190 *
191 * // Use the encoded sequence...
192 *
193 * free(buf);
194 * @endcode
195 *
196 * ## Example: Direct encoding with static buffer
197 *
198 * @code{.c}
199 * // Most escape sequences are short, so a static buffer often suffices
200 * char buf[128];
201 * size_t written = 0;
202 * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
203 *
204 * if (result == GHOSTTY_SUCCESS) {
205 * // Write the encoded sequence to the terminal
206 * write(pty_fd, buf, written);
207 * } else if (result == GHOSTTY_OUT_OF_MEMORY) {
208 * // Buffer too small, written contains required size
209 * char *dynamic_buf = malloc(written);
210 * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written);
211 * assert(result == GHOSTTY_SUCCESS);
212 * write(pty_fd, dynamic_buf, written);
213 * free(dynamic_buf);
214 * }
215 * @endcode
216 *
217 * @ingroup key
218 */
219GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len);
220
221#endif /* GHOSTTY_VT_KEY_ENCODER_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h created+474
...@@ -0,0 +1,474 @@
1/**
2 * @file event.h
3 *
4 * Key event representation and manipulation.
5 */
6
7#ifndef GHOSTTY_VT_KEY_EVENT_H
8#define GHOSTTY_VT_KEY_EVENT_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/result.h>
14#include <ghostty/vt/allocator.h>
15
16/**
17 * Opaque handle to a key event.
18 *
19 * This handle represents a keyboard input event containing information about
20 * the physical key pressed, modifiers, and generated text.
21 *
22 * @ingroup key
23 */
24typedef struct GhosttyKeyEvent *GhosttyKeyEvent;
25
26/**
27 * Keyboard input event types.
28 *
29 * @ingroup key
30 */
31typedef enum {
32 /** Key was released */
33 GHOSTTY_KEY_ACTION_RELEASE = 0,
34 /** Key was pressed */
35 GHOSTTY_KEY_ACTION_PRESS = 1,
36 /** Key is being repeated (held down) */
37 GHOSTTY_KEY_ACTION_REPEAT = 2,
38} GhosttyKeyAction;
39
40/**
41 * Keyboard modifier keys bitmask.
42 *
43 * A bitmask representing all keyboard modifiers. This tracks which modifier keys
44 * are pressed and, where supported by the platform, which side (left or right)
45 * of each modifier is active.
46 *
47 * Use the GHOSTTY_MODS_* constants to test and set individual modifiers.
48 *
49 * Modifier side bits are only meaningful when the corresponding modifier bit is set.
50 * Not all platforms support distinguishing between left and right modifier
51 * keys and Ghostty is built to expect that some platforms may not provide this
52 * information.
53 *
54 * @ingroup key
55 */
56typedef uint16_t GhosttyMods;
57
58/** Shift key is pressed */
59#define GHOSTTY_MODS_SHIFT (1 << 0)
60/** Control key is pressed */
61#define GHOSTTY_MODS_CTRL (1 << 1)
62/** Alt/Option key is pressed */
63#define GHOSTTY_MODS_ALT (1 << 2)
64/** Super/Command/Windows key is pressed */
65#define GHOSTTY_MODS_SUPER (1 << 3)
66/** Caps Lock is active */
67#define GHOSTTY_MODS_CAPS_LOCK (1 << 4)
68/** Num Lock is active */
69#define GHOSTTY_MODS_NUM_LOCK (1 << 5)
70
71/**
72 * Right shift is pressed (0 = left, 1 = right).
73 * Only meaningful when GHOSTTY_MODS_SHIFT is set.
74 */
75#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6)
76/**
77 * Right ctrl is pressed (0 = left, 1 = right).
78 * Only meaningful when GHOSTTY_MODS_CTRL is set.
79 */
80#define GHOSTTY_MODS_CTRL_SIDE (1 << 7)
81/**
82 * Right alt is pressed (0 = left, 1 = right).
83 * Only meaningful when GHOSTTY_MODS_ALT is set.
84 */
85#define GHOSTTY_MODS_ALT_SIDE (1 << 8)
86/**
87 * Right super is pressed (0 = left, 1 = right).
88 * Only meaningful when GHOSTTY_MODS_SUPER is set.
89 */
90#define GHOSTTY_MODS_SUPER_SIDE (1 << 9)
91
92/**
93 * Physical key codes.
94 *
95 * The set of key codes that Ghostty is aware of. These represent physical keys
96 * on the keyboard and are layout-independent. For example, the "a" key on a US
97 * keyboard is the same as the "ф" key on a Russian keyboard, but both will
98 * report the same key_a value.
99 *
100 * Layout-dependent strings are provided separately as UTF-8 text and are produced
101 * by the platform. These values are based on the W3C UI Events KeyboardEvent code
102 * standard. See: https://www.w3.org/TR/uievents-code
103 *
104 * @ingroup key
105 */
106typedef enum {
107 GHOSTTY_KEY_UNIDENTIFIED = 0,
108
109 // Writing System Keys (W3C § 3.1.1)
110 GHOSTTY_KEY_BACKQUOTE,
111 GHOSTTY_KEY_BACKSLASH,
112 GHOSTTY_KEY_BRACKET_LEFT,
113 GHOSTTY_KEY_BRACKET_RIGHT,
114 GHOSTTY_KEY_COMMA,
115 GHOSTTY_KEY_DIGIT_0,
116 GHOSTTY_KEY_DIGIT_1,
117 GHOSTTY_KEY_DIGIT_2,
118 GHOSTTY_KEY_DIGIT_3,
119 GHOSTTY_KEY_DIGIT_4,
120 GHOSTTY_KEY_DIGIT_5,
121 GHOSTTY_KEY_DIGIT_6,
122 GHOSTTY_KEY_DIGIT_7,
123 GHOSTTY_KEY_DIGIT_8,
124 GHOSTTY_KEY_DIGIT_9,
125 GHOSTTY_KEY_EQUAL,
126 GHOSTTY_KEY_INTL_BACKSLASH,
127 GHOSTTY_KEY_INTL_RO,
128 GHOSTTY_KEY_INTL_YEN,
129 GHOSTTY_KEY_A,
130 GHOSTTY_KEY_B,
131 GHOSTTY_KEY_C,
132 GHOSTTY_KEY_D,
133 GHOSTTY_KEY_E,
134 GHOSTTY_KEY_F,
135 GHOSTTY_KEY_G,
136 GHOSTTY_KEY_H,
137 GHOSTTY_KEY_I,
138 GHOSTTY_KEY_J,
139 GHOSTTY_KEY_K,
140 GHOSTTY_KEY_L,
141 GHOSTTY_KEY_M,
142 GHOSTTY_KEY_N,
143 GHOSTTY_KEY_O,
144 GHOSTTY_KEY_P,
145 GHOSTTY_KEY_Q,
146 GHOSTTY_KEY_R,
147 GHOSTTY_KEY_S,
148 GHOSTTY_KEY_T,
149 GHOSTTY_KEY_U,
150 GHOSTTY_KEY_V,
151 GHOSTTY_KEY_W,
152 GHOSTTY_KEY_X,
153 GHOSTTY_KEY_Y,
154 GHOSTTY_KEY_Z,
155 GHOSTTY_KEY_MINUS,
156 GHOSTTY_KEY_PERIOD,
157 GHOSTTY_KEY_QUOTE,
158 GHOSTTY_KEY_SEMICOLON,
159 GHOSTTY_KEY_SLASH,
160
161 // Functional Keys (W3C § 3.1.2)
162 GHOSTTY_KEY_ALT_LEFT,
163 GHOSTTY_KEY_ALT_RIGHT,
164 GHOSTTY_KEY_BACKSPACE,
165 GHOSTTY_KEY_CAPS_LOCK,
166 GHOSTTY_KEY_CONTEXT_MENU,
167 GHOSTTY_KEY_CONTROL_LEFT,
168 GHOSTTY_KEY_CONTROL_RIGHT,
169 GHOSTTY_KEY_ENTER,
170 GHOSTTY_KEY_META_LEFT,
171 GHOSTTY_KEY_META_RIGHT,
172 GHOSTTY_KEY_SHIFT_LEFT,
173 GHOSTTY_KEY_SHIFT_RIGHT,
174 GHOSTTY_KEY_SPACE,
175 GHOSTTY_KEY_TAB,
176 GHOSTTY_KEY_CONVERT,
177 GHOSTTY_KEY_KANA_MODE,
178 GHOSTTY_KEY_NON_CONVERT,
179
180 // Control Pad Section (W3C § 3.2)
181 GHOSTTY_KEY_DELETE,
182 GHOSTTY_KEY_END,
183 GHOSTTY_KEY_HELP,
184 GHOSTTY_KEY_HOME,
185 GHOSTTY_KEY_INSERT,
186 GHOSTTY_KEY_PAGE_DOWN,
187 GHOSTTY_KEY_PAGE_UP,
188
189 // Arrow Pad Section (W3C § 3.3)
190 GHOSTTY_KEY_ARROW_DOWN,
191 GHOSTTY_KEY_ARROW_LEFT,
192 GHOSTTY_KEY_ARROW_RIGHT,
193 GHOSTTY_KEY_ARROW_UP,
194
195 // Numpad Section (W3C § 3.4)
196 GHOSTTY_KEY_NUM_LOCK,
197 GHOSTTY_KEY_NUMPAD_0,
198 GHOSTTY_KEY_NUMPAD_1,
199 GHOSTTY_KEY_NUMPAD_2,
200 GHOSTTY_KEY_NUMPAD_3,
201 GHOSTTY_KEY_NUMPAD_4,
202 GHOSTTY_KEY_NUMPAD_5,
203 GHOSTTY_KEY_NUMPAD_6,
204 GHOSTTY_KEY_NUMPAD_7,
205 GHOSTTY_KEY_NUMPAD_8,
206 GHOSTTY_KEY_NUMPAD_9,
207 GHOSTTY_KEY_NUMPAD_ADD,
208 GHOSTTY_KEY_NUMPAD_BACKSPACE,
209 GHOSTTY_KEY_NUMPAD_CLEAR,
210 GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
211 GHOSTTY_KEY_NUMPAD_COMMA,
212 GHOSTTY_KEY_NUMPAD_DECIMAL,
213 GHOSTTY_KEY_NUMPAD_DIVIDE,
214 GHOSTTY_KEY_NUMPAD_ENTER,
215 GHOSTTY_KEY_NUMPAD_EQUAL,
216 GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
217 GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
218 GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
219 GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
220 GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
221 GHOSTTY_KEY_NUMPAD_MULTIPLY,
222 GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
223 GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
224 GHOSTTY_KEY_NUMPAD_SUBTRACT,
225 GHOSTTY_KEY_NUMPAD_SEPARATOR,
226 GHOSTTY_KEY_NUMPAD_UP,
227 GHOSTTY_KEY_NUMPAD_DOWN,
228 GHOSTTY_KEY_NUMPAD_RIGHT,
229 GHOSTTY_KEY_NUMPAD_LEFT,
230 GHOSTTY_KEY_NUMPAD_BEGIN,
231 GHOSTTY_KEY_NUMPAD_HOME,
232 GHOSTTY_KEY_NUMPAD_END,
233 GHOSTTY_KEY_NUMPAD_INSERT,
234 GHOSTTY_KEY_NUMPAD_DELETE,
235 GHOSTTY_KEY_NUMPAD_PAGE_UP,
236 GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
237
238 // Function Section (W3C § 3.5)
239 GHOSTTY_KEY_ESCAPE,
240 GHOSTTY_KEY_F1,
241 GHOSTTY_KEY_F2,
242 GHOSTTY_KEY_F3,
243 GHOSTTY_KEY_F4,
244 GHOSTTY_KEY_F5,
245 GHOSTTY_KEY_F6,
246 GHOSTTY_KEY_F7,
247 GHOSTTY_KEY_F8,
248 GHOSTTY_KEY_F9,
249 GHOSTTY_KEY_F10,
250 GHOSTTY_KEY_F11,
251 GHOSTTY_KEY_F12,
252 GHOSTTY_KEY_F13,
253 GHOSTTY_KEY_F14,
254 GHOSTTY_KEY_F15,
255 GHOSTTY_KEY_F16,
256 GHOSTTY_KEY_F17,
257 GHOSTTY_KEY_F18,
258 GHOSTTY_KEY_F19,
259 GHOSTTY_KEY_F20,
260 GHOSTTY_KEY_F21,
261 GHOSTTY_KEY_F22,
262 GHOSTTY_KEY_F23,
263 GHOSTTY_KEY_F24,
264 GHOSTTY_KEY_F25,
265 GHOSTTY_KEY_FN,
266 GHOSTTY_KEY_FN_LOCK,
267 GHOSTTY_KEY_PRINT_SCREEN,
268 GHOSTTY_KEY_SCROLL_LOCK,
269 GHOSTTY_KEY_PAUSE,
270
271 // Media Keys (W3C § 3.6)
272 GHOSTTY_KEY_BROWSER_BACK,
273 GHOSTTY_KEY_BROWSER_FAVORITES,
274 GHOSTTY_KEY_BROWSER_FORWARD,
275 GHOSTTY_KEY_BROWSER_HOME,
276 GHOSTTY_KEY_BROWSER_REFRESH,
277 GHOSTTY_KEY_BROWSER_SEARCH,
278 GHOSTTY_KEY_BROWSER_STOP,
279 GHOSTTY_KEY_EJECT,
280 GHOSTTY_KEY_LAUNCH_APP_1,
281 GHOSTTY_KEY_LAUNCH_APP_2,
282 GHOSTTY_KEY_LAUNCH_MAIL,
283 GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
284 GHOSTTY_KEY_MEDIA_SELECT,
285 GHOSTTY_KEY_MEDIA_STOP,
286 GHOSTTY_KEY_MEDIA_TRACK_NEXT,
287 GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
288 GHOSTTY_KEY_POWER,
289 GHOSTTY_KEY_SLEEP,
290 GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
291 GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
292 GHOSTTY_KEY_AUDIO_VOLUME_UP,
293 GHOSTTY_KEY_WAKE_UP,
294
295 // Legacy, Non-standard, and Special Keys (W3C § 3.7)
296 GHOSTTY_KEY_COPY,
297 GHOSTTY_KEY_CUT,
298 GHOSTTY_KEY_PASTE,
299} GhosttyKey;
300
301/**
302 * Create a new key event instance.
303 *
304 * Creates a new key event with default values. The event must be freed using
305 * ghostty_key_event_free() when no longer needed.
306 *
307 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
308 * @param event Pointer to store the created key event handle
309 * @return GHOSTTY_SUCCESS on success, or an error code on failure
310 *
311 * @ingroup key
312 */
313GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event);
314
315/**
316 * Free a key event instance.
317 *
318 * Releases all resources associated with the key event. After this call,
319 * the event handle becomes invalid and must not be used.
320 *
321 * @param event The key event handle to free (may be NULL)
322 *
323 * @ingroup key
324 */
325void ghostty_key_event_free(GhosttyKeyEvent event);
326
327/**
328 * Set the key action (press, release, repeat).
329 *
330 * @param event The key event handle, must not be NULL
331 * @param action The action to set
332 *
333 * @ingroup key
334 */
335void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action);
336
337/**
338 * Get the key action (press, release, repeat).
339 *
340 * @param event The key event handle, must not be NULL
341 * @return The key action
342 *
343 * @ingroup key
344 */
345GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event);
346
347/**
348 * Set the physical key code.
349 *
350 * @param event The key event handle, must not be NULL
351 * @param key The physical key code to set
352 *
353 * @ingroup key
354 */
355void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key);
356
357/**
358 * Get the physical key code.
359 *
360 * @param event The key event handle, must not be NULL
361 * @return The physical key code
362 *
363 * @ingroup key
364 */
365GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event);
366
367/**
368 * Set the modifier keys bitmask.
369 *
370 * @param event The key event handle, must not be NULL
371 * @param mods The modifier keys bitmask to set
372 *
373 * @ingroup key
374 */
375void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods);
376
377/**
378 * Get the modifier keys bitmask.
379 *
380 * @param event The key event handle, must not be NULL
381 * @return The modifier keys bitmask
382 *
383 * @ingroup key
384 */
385GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event);
386
387/**
388 * Set the consumed modifiers bitmask.
389 *
390 * @param event The key event handle, must not be NULL
391 * @param consumed_mods The consumed modifiers bitmask to set
392 *
393 * @ingroup key
394 */
395void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods);
396
397/**
398 * Get the consumed modifiers bitmask.
399 *
400 * @param event The key event handle, must not be NULL
401 * @return The consumed modifiers bitmask
402 *
403 * @ingroup key
404 */
405GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event);
406
407/**
408 * Set whether the key event is part of a composition sequence.
409 *
410 * @param event The key event handle, must not be NULL
411 * @param composing Whether the key event is part of a composition sequence
412 *
413 * @ingroup key
414 */
415void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing);
416
417/**
418 * Get whether the key event is part of a composition sequence.
419 *
420 * @param event The key event handle, must not be NULL
421 * @return Whether the key event is part of a composition sequence
422 *
423 * @ingroup key
424 */
425bool ghostty_key_event_get_composing(GhosttyKeyEvent event);
426
427/**
428 * Set the UTF-8 text generated by the key event.
429 *
430 * The key event does NOT take ownership of the text pointer. The caller
431 * must ensure the string remains valid for the lifetime needed by the event.
432 *
433 * @param event The key event handle, must not be NULL
434 * @param utf8 The UTF-8 text to set (or NULL for empty)
435 * @param len Length of the UTF-8 text in bytes
436 *
437 * @ingroup key
438 */
439void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len);
440
441/**
442 * Get the UTF-8 text generated by the key event.
443 *
444 * The returned pointer is valid until the event is freed or the UTF-8 text is modified.
445 *
446 * @param event The key event handle, must not be NULL
447 * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL)
448 * @return The UTF-8 text (or NULL for empty)
449 *
450 * @ingroup key
451 */
452const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len);
453
454/**
455 * Set the unshifted Unicode codepoint.
456 *
457 * @param event The key event handle, must not be NULL
458 * @param codepoint The unshifted Unicode codepoint to set
459 *
460 * @ingroup key
461 */
462void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint);
463
464/**
465 * Get the unshifted Unicode codepoint.
466 *
467 * @param event The key event handle, must not be NULL
468 * @return The unshifted Unicode codepoint
469 *
470 * @ingroup key
471 */
472uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event);
473
474#endif /* GHOSTTY_VT_KEY_EVENT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h created+233
...@@ -0,0 +1,233 @@
1/**
2 * @file osc.h
3 *
4 * OSC (Operating System Command) sequence parser and command handling.
5 */
6
7#ifndef GHOSTTY_VT_OSC_H
8#define GHOSTTY_VT_OSC_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/result.h>
14#include <ghostty/vt/allocator.h>
15
16/**
17 * Opaque handle to an OSC parser instance.
18 *
19 * This handle represents an OSC (Operating System Command) parser that can
20 * be used to parse the contents of OSC sequences.
21 *
22 * @ingroup osc
23 */
24typedef struct GhosttyOscParser *GhosttyOscParser;
25
26/**
27 * Opaque handle to a single OSC command.
28 *
29 * This handle represents a parsed OSC (Operating System Command) command.
30 * The command can be queried for its type and associated data.
31 *
32 * @ingroup osc
33 */
34typedef struct GhosttyOscCommand *GhosttyOscCommand;
35
36/** @defgroup osc OSC Parser
37 *
38 * OSC (Operating System Command) sequence parser and command handling.
39 *
40 * The parser operates in a streaming fashion, processing input byte-by-byte
41 * to handle OSC sequences that may arrive in fragments across multiple reads.
42 * This interface makes it easy to integrate into most environments and avoids
43 * over-allocating buffers.
44 *
45 * ## Basic Usage
46 *
47 * 1. Create a parser instance with ghostty_osc_new()
48 * 2. Feed bytes to the parser using ghostty_osc_next()
49 * 3. Finalize parsing with ghostty_osc_end() to get the command
50 * 4. Query command type and extract data using ghostty_osc_command_type()
51 * and ghostty_osc_command_data()
52 * 5. Free the parser with ghostty_osc_free() when done
53 *
54 * @{
55 */
56
57/**
58 * OSC command types.
59 *
60 * @ingroup osc
61 */
62typedef enum {
63 GHOSTTY_OSC_COMMAND_INVALID = 0,
64 GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1,
65 GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2,
66 GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3,
67 GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4,
68 GHOSTTY_OSC_COMMAND_REPORT_PWD = 5,
69 GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6,
70 GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7,
71 GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8,
72 GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9,
73 GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10,
74 GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11,
75 GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12,
76 GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13,
77 GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14,
78 GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15,
79 GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16,
80 GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17,
81 GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18,
82 GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19,
83 GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
84 GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
85 GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
86} GhosttyOscCommandType;
87
88/**
89 * OSC command data types.
90 *
91 * These values specify what type of data to extract from an OSC command
92 * using `ghostty_osc_command_data`.
93 *
94 * @ingroup osc
95 */
96typedef enum {
97 /** Invalid data type. Never results in any data extraction. */
98 GHOSTTY_OSC_DATA_INVALID = 0,
99
100 /**
101 * Window title string data.
102 *
103 * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE
104 *
105 * Output type: const char ** (pointer to null-terminated string)
106 *
107 * Lifetime: Valid until the next call to any ghostty_osc_* function with
108 * the same parser instance. Memory is owned by the parser.
109 */
110 GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1,
111} GhosttyOscCommandData;
112
113/**
114 * Create a new OSC parser instance.
115 *
116 * Creates a new OSC (Operating System Command) parser using the provided
117 * allocator. The parser must be freed using ghostty_vt_osc_free() when
118 * no longer needed.
119 *
120 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
121 * @param parser Pointer to store the created parser handle
122 * @return GHOSTTY_SUCCESS on success, or an error code on failure
123 *
124 * @ingroup osc
125 */
126GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser);
127
128/**
129 * Free an OSC parser instance.
130 *
131 * Releases all resources associated with the OSC parser. After this call,
132 * the parser handle becomes invalid and must not be used.
133 *
134 * @param parser The parser handle to free (may be NULL)
135 *
136 * @ingroup osc
137 */
138void ghostty_osc_free(GhosttyOscParser parser);
139
140/**
141 * Reset an OSC parser instance to its initial state.
142 *
143 * Resets the parser state, clearing any partially parsed OSC sequences
144 * and returning the parser to its initial state. This is useful for
145 * reusing a parser instance or recovering from parse errors.
146 *
147 * @param parser The parser handle to reset, must not be null.
148 *
149 * @ingroup osc
150 */
151void ghostty_osc_reset(GhosttyOscParser parser);
152
153/**
154 * Parse the next byte in an OSC sequence.
155 *
156 * Processes a single byte as part of an OSC sequence. The parser maintains
157 * internal state to track the progress through the sequence. Call this
158 * function for each byte in the sequence data.
159 *
160 * When finished pumping the parser with bytes, call ghostty_osc_end
161 * to get the final result.
162 *
163 * @param parser The parser handle, must not be null.
164 * @param byte The next byte to parse
165 *
166 * @ingroup osc
167 */
168void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte);
169
170/**
171 * Finalize OSC parsing and retrieve the parsed command.
172 *
173 * Call this function after feeding all bytes of an OSC sequence to the parser
174 * using ghostty_osc_next() with the exception of the terminating character
175 * (ESC or ST). This function finalizes the parsing process and returns the
176 * parsed OSC command.
177 *
178 * The return value is never NULL. Invalid commands will return a command
179 * with type GHOSTTY_OSC_COMMAND_INVALID.
180 *
181 * The terminator parameter specifies the byte that terminated the OSC sequence
182 * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is
183 * preserved in the parsed command so that responses can use the same terminator
184 * format for better compatibility with the calling program. For commands that
185 * do not require a response, this parameter is ignored and the resulting
186 * command will not retain the terminator information.
187 *
188 * The returned command handle is valid until the next call to any
189 * `ghostty_osc_*` function with the same parser instance with the exception
190 * of command introspection functions such as `ghostty_osc_command_type`.
191 *
192 * @param parser The parser handle, must not be null.
193 * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST)
194 * @return Handle to the parsed OSC command
195 *
196 * @ingroup osc
197 */
198GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator);
199
200/**
201 * Get the type of an OSC command.
202 *
203 * Returns the type identifier for the given OSC command. This can be used
204 * to determine what kind of command was parsed and what data might be
205 * available from it.
206 *
207 * @param command The OSC command handle to query (may be NULL)
208 * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL
209 *
210 * @ingroup osc
211 */
212GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command);
213
214/**
215 * Extract data from an OSC command.
216 *
217 * Extracts typed data from the given OSC command based on the specified
218 * data type. The output pointer must be of the appropriate type for the
219 * requested data kind. Valid command types, output types, and memory
220 * safety information are documented in the `GhosttyOscCommandData` enum.
221 *
222 * @param command The OSC command handle to query (may be NULL)
223 * @param data The type of data to extract
224 * @param out Pointer to store the extracted data (type depends on data parameter)
225 * @return true if data extraction was successful, false otherwise
226 *
227 * @ingroup osc
228 */
229bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out);
230
231/** @} */
232
233#endif /* GHOSTTY_VT_OSC_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h created+75
...@@ -0,0 +1,75 @@
1/**
2 * @file paste.h
3 *
4 * Paste utilities - validate and encode paste data for terminal input.
5 */
6
7#ifndef GHOSTTY_VT_PASTE_H
8#define GHOSTTY_VT_PASTE_H
9
10/** @defgroup paste Paste Utilities
11 *
12 * Utilities for validating paste data safety.
13 *
14 * ## Basic Usage
15 *
16 * Use ghostty_paste_is_safe() to check if paste data contains potentially
17 * dangerous sequences before sending it to the terminal.
18 *
19 * ## Example
20 *
21 * @code{.c}
22 * #include <stdio.h>
23 * #include <string.h>
24 * #include <ghostty/vt.h>
25 *
26 * int main() {
27 * const char* safe_data = "hello world";
28 * const char* unsafe_data = "rm -rf /\n";
29 *
30 * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) {
31 * printf("Safe to paste\n");
32 * }
33 *
34 * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) {
35 * printf("Unsafe! Contains newline\n");
36 * }
37 *
38 * return 0;
39 * }
40 * @endcode
41 *
42 * @{
43 */
44
45#include <stdbool.h>
46#include <stddef.h>
47
48#ifdef __cplusplus
49extern "C" {
50#endif
51
52/**
53 * Check if paste data is safe to paste into the terminal.
54 *
55 * Data is considered unsafe if it contains:
56 * - Newlines (`\n`) which can inject commands
57 * - The bracketed paste end sequence (`\x1b[201~`) which can be used
58 * to exit bracketed paste mode and inject commands
59 *
60 * This check is conservative and considers data unsafe regardless of
61 * current terminal state.
62 *
63 * @param data The paste data to check (must not be NULL)
64 * @param len The length of the data in bytes
65 * @return true if the data is safe to paste, false otherwise
66 */
67bool ghostty_paste_is_safe(const char* data, size_t len);
68
69#ifdef __cplusplus
70}
71#endif
72
73/** @} */
74
75#endif /* GHOSTTY_VT_PASTE_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h created+22
...@@ -0,0 +1,22 @@
1/**
2 * @file result.h
3 *
4 * Result codes for libghostty-vt operations.
5 */
6
7#ifndef GHOSTTY_VT_RESULT_H
8#define GHOSTTY_VT_RESULT_H
9
10/**
11 * Result codes for libghostty-vt operations.
12 */
13typedef enum {
14 /** Operation completed successfully */
15 GHOSTTY_SUCCESS = 0,
16 /** Operation failed due to failed allocation */
17 GHOSTTY_OUT_OF_MEMORY = -1,
18 /** Operation failed due to invalid value */
19 GHOSTTY_INVALID_VALUE = -2,
20} GhosttyResult;
21
22#endif /* GHOSTTY_VT_RESULT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h created+394
...@@ -0,0 +1,394 @@
1/**
2 * @file sgr.h
3 *
4 * SGR (Select Graphic Rendition) attribute parsing and handling.
5 */
6
7#ifndef GHOSTTY_VT_SGR_H
8#define GHOSTTY_VT_SGR_H
9
10/** @defgroup sgr SGR Parser
11 *
12 * SGR (Select Graphic Rendition) attribute parser.
13 *
14 * SGR sequences are the syntax used to set styling attributes such as
15 * bold, italic, underline, and colors for text in terminal emulators.
16 * For example, you may be familiar with sequences like `ESC[1;31m`. The
17 * `1;31` is the SGR attribute list.
18 *
19 * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`)
20 * and returns individual text attributes like bold, italic, colors, etc.
21 * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed,
22 * and handles various color formats including 8-color, 16-color, 256-color,
23 * X11 named colors, and RGB in multiple formats.
24 *
25 * ## Basic Usage
26 *
27 * 1. Create a parser instance with ghostty_sgr_new()
28 * 2. Set SGR parameters with ghostty_sgr_set_params()
29 * 3. Iterate through attributes using ghostty_sgr_next()
30 * 4. Free the parser with ghostty_sgr_free() when done
31 *
32 * ## Example
33 *
34 * @code{.c}
35 * #include <assert.h>
36 * #include <stdio.h>
37 * #include <ghostty/vt.h>
38 *
39 * int main() {
40 * // Create parser
41 * GhosttySgrParser parser;
42 * GhosttyResult result = ghostty_sgr_new(NULL, &parser);
43 * assert(result == GHOSTTY_SUCCESS);
44 *
45 * // Parse "bold, red foreground" sequence: ESC[1;31m
46 * uint16_t params[] = {1, 31};
47 * result = ghostty_sgr_set_params(parser, params, NULL, 2);
48 * assert(result == GHOSTTY_SUCCESS);
49 *
50 * // Iterate through attributes
51 * GhosttySgrAttribute attr;
52 * while (ghostty_sgr_next(parser, &attr)) {
53 * switch (attr.tag) {
54 * case GHOSTTY_SGR_ATTR_BOLD:
55 * printf("Bold enabled\n");
56 * break;
57 * case GHOSTTY_SGR_ATTR_FG_8:
58 * printf("Foreground color: %d\n", attr.value.fg_8);
59 * break;
60 * default:
61 * break;
62 * }
63 * }
64 *
65 * // Cleanup
66 * ghostty_sgr_free(parser);
67 * return 0;
68 * }
69 * @endcode
70 *
71 * @{
72 */
73
74#include <ghostty/vt/allocator.h>
75#include <ghostty/vt/color.h>
76#include <ghostty/vt/result.h>
77#include <stdbool.h>
78#include <stddef.h>
79#include <stdint.h>
80
81#ifdef __cplusplus
82extern "C" {
83#endif
84
85/**
86 * Opaque handle to an SGR parser instance.
87 *
88 * This handle represents an SGR (Select Graphic Rendition) parser that can
89 * be used to parse SGR sequences and extract individual text attributes.
90 *
91 * @ingroup sgr
92 */
93typedef struct GhosttySgrParser* GhosttySgrParser;
94
95/**
96 * SGR attribute tags.
97 *
98 * These values identify the type of an SGR attribute in a tagged union.
99 * Use the tag to determine which field in the attribute value union to access.
100 *
101 * @ingroup sgr
102 */
103typedef enum {
104 GHOSTTY_SGR_ATTR_UNSET = 0,
105 GHOSTTY_SGR_ATTR_UNKNOWN = 1,
106 GHOSTTY_SGR_ATTR_BOLD = 2,
107 GHOSTTY_SGR_ATTR_RESET_BOLD = 3,
108 GHOSTTY_SGR_ATTR_ITALIC = 4,
109 GHOSTTY_SGR_ATTR_RESET_ITALIC = 5,
110 GHOSTTY_SGR_ATTR_FAINT = 6,
111 GHOSTTY_SGR_ATTR_UNDERLINE = 7,
112 GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8,
113 GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9,
114 GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10,
115 GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11,
116 GHOSTTY_SGR_ATTR_OVERLINE = 12,
117 GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13,
118 GHOSTTY_SGR_ATTR_BLINK = 14,
119 GHOSTTY_SGR_ATTR_RESET_BLINK = 15,
120 GHOSTTY_SGR_ATTR_INVERSE = 16,
121 GHOSTTY_SGR_ATTR_RESET_INVERSE = 17,
122 GHOSTTY_SGR_ATTR_INVISIBLE = 18,
123 GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19,
124 GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20,
125 GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21,
126 GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22,
127 GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23,
128 GHOSTTY_SGR_ATTR_BG_8 = 24,
129 GHOSTTY_SGR_ATTR_FG_8 = 25,
130 GHOSTTY_SGR_ATTR_RESET_FG = 26,
131 GHOSTTY_SGR_ATTR_RESET_BG = 27,
132 GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28,
133 GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29,
134 GHOSTTY_SGR_ATTR_BG_256 = 30,
135 GHOSTTY_SGR_ATTR_FG_256 = 31,
136} GhosttySgrAttributeTag;
137
138/**
139 * Underline style types.
140 *
141 * @ingroup sgr
142 */
143typedef enum {
144 GHOSTTY_SGR_UNDERLINE_NONE = 0,
145 GHOSTTY_SGR_UNDERLINE_SINGLE = 1,
146 GHOSTTY_SGR_UNDERLINE_DOUBLE = 2,
147 GHOSTTY_SGR_UNDERLINE_CURLY = 3,
148 GHOSTTY_SGR_UNDERLINE_DOTTED = 4,
149 GHOSTTY_SGR_UNDERLINE_DASHED = 5,
150} GhosttySgrUnderline;
151
152/**
153 * Unknown SGR attribute data.
154 *
155 * Contains the full parameter list and the partial list where parsing
156 * encountered an unknown or invalid sequence.
157 *
158 * @ingroup sgr
159 */
160typedef struct {
161 const uint16_t* full_ptr;
162 size_t full_len;
163 const uint16_t* partial_ptr;
164 size_t partial_len;
165} GhosttySgrUnknown;
166
167/**
168 * SGR attribute value union.
169 *
170 * This union contains all possible attribute values. Use the tag field
171 * to determine which union member is active. Attributes without associated
172 * data (like bold, italic) don't use the union value.
173 *
174 * @ingroup sgr
175 */
176typedef union {
177 GhosttySgrUnknown unknown;
178 GhosttySgrUnderline underline;
179 GhosttyColorRgb underline_color;
180 GhosttyColorPaletteIndex underline_color_256;
181 GhosttyColorRgb direct_color_fg;
182 GhosttyColorRgb direct_color_bg;
183 GhosttyColorPaletteIndex bg_8;
184 GhosttyColorPaletteIndex fg_8;
185 GhosttyColorPaletteIndex bright_bg_8;
186 GhosttyColorPaletteIndex bright_fg_8;
187 GhosttyColorPaletteIndex bg_256;
188 GhosttyColorPaletteIndex fg_256;
189 uint64_t _padding[8];
190} GhosttySgrAttributeValue;
191
192/**
193 * SGR attribute (tagged union).
194 *
195 * A complete SGR attribute with both its type tag and associated value.
196 * Always check the tag field to determine which value union member is valid.
197 *
198 * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be
199 * identified by tag alone; the value union is not used for these and
200 * the memory in the value field is undefined.
201 *
202 * @ingroup sgr
203 */
204typedef struct {
205 GhosttySgrAttributeTag tag;
206 GhosttySgrAttributeValue value;
207} GhosttySgrAttribute;
208
209/**
210 * Create a new SGR parser instance.
211 *
212 * Creates a new SGR (Select Graphic Rendition) parser using the provided
213 * allocator. The parser must be freed using ghostty_sgr_free() when
214 * no longer needed.
215 *
216 * @param allocator Pointer to the allocator to use for memory management, or
217 * NULL to use the default allocator
218 * @param parser Pointer to store the created parser handle
219 * @return GHOSTTY_SUCCESS on success, or an error code on failure
220 *
221 * @ingroup sgr
222 */
223GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator,
224 GhosttySgrParser* parser);
225
226/**
227 * Free an SGR parser instance.
228 *
229 * Releases all resources associated with the SGR parser. After this call,
230 * the parser handle becomes invalid and must not be used. This includes
231 * any attributes previously returned by ghostty_sgr_next().
232 *
233 * @param parser The parser handle to free (may be NULL)
234 *
235 * @ingroup sgr
236 */
237void ghostty_sgr_free(GhosttySgrParser parser);
238
239/**
240 * Reset an SGR parser instance to the beginning of the parameter list.
241 *
242 * Resets the parser's iteration state without clearing the parameters.
243 * After calling this, ghostty_sgr_next() will start from the beginning
244 * of the parameter list again.
245 *
246 * @param parser The parser handle to reset, must not be NULL
247 *
248 * @ingroup sgr
249 */
250void ghostty_sgr_reset(GhosttySgrParser parser);
251
252/**
253 * Set SGR parameters for parsing.
254 *
255 * Sets the SGR parameter list to parse. Parameters are the numeric values
256 * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}).
257 *
258 * The separators array optionally specifies the separator type for each
259 * parameter position. Each byte should be either ';' for semicolon or ':'
260 * for colon. This is needed for certain color formats that use colon
261 * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator
262 * values are treated as semicolons. The separators array must have the same
263 * length as the params array, if it is not NULL.
264 *
265 * If separators is NULL, all parameters are assumed to be semicolon-separated.
266 *
267 * This function makes an internal copy of the parameter and separator data,
268 * so the caller can safely free or modify the input arrays after this call.
269 *
270 * After calling this function, the parser is automatically reset and ready
271 * to iterate from the beginning.
272 *
273 * @param parser The parser handle, must not be NULL
274 * @param params Array of SGR parameter values
275 * @param separators Optional array of separator characters (';' or ':'), or
276 * NULL
277 * @param len Number of parameters (and separators if provided)
278 * @return GHOSTTY_SUCCESS on success, or an error code on failure
279 *
280 * @ingroup sgr
281 */
282GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser,
283 const uint16_t* params,
284 const char* separators,
285 size_t len);
286
287/**
288 * Get the next SGR attribute.
289 *
290 * Parses and returns the next attribute from the parameter list.
291 * Call this function repeatedly until it returns false to process
292 * all attributes in the sequence.
293 *
294 * @param parser The parser handle, must not be NULL
295 * @param attr Pointer to store the next attribute
296 * @return true if an attribute was returned, false if no more attributes
297 *
298 * @ingroup sgr
299 */
300bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr);
301
302/**
303 * Get the full parameter list from an unknown SGR attribute.
304 *
305 * This function retrieves the full parameter list that was provided to the
306 * parser when an unknown attribute was encountered. Primarily useful in
307 * WebAssembly environments where accessing struct fields directly is difficult.
308 *
309 * @param unknown The unknown attribute data
310 * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
311 * @return The length of the full parameter array
312 *
313 * @ingroup sgr
314 */
315size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown,
316 const uint16_t** ptr);
317
318/**
319 * Get the partial parameter list from an unknown SGR attribute.
320 *
321 * This function retrieves the partial parameter list where parsing stopped
322 * when an unknown attribute was encountered. Primarily useful in WebAssembly
323 * environments where accessing struct fields directly is difficult.
324 *
325 * @param unknown The unknown attribute data
326 * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
327 * @return The length of the partial parameter array
328 *
329 * @ingroup sgr
330 */
331size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown,
332 const uint16_t** ptr);
333
334/**
335 * Get the tag from an SGR attribute.
336 *
337 * This function extracts the tag that identifies which type of attribute
338 * this is. Primarily useful in WebAssembly environments where accessing
339 * struct fields directly is difficult.
340 *
341 * @param attr The SGR attribute
342 * @return The attribute tag
343 *
344 * @ingroup sgr
345 */
346GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr);
347
348/**
349 * Get the value from an SGR attribute.
350 *
351 * This function returns a pointer to the value union from an SGR attribute. Use
352 * the tag to determine which field of the union is valid. Primarily useful in
353 * WebAssembly environments where accessing struct fields directly is difficult.
354 *
355 * @param attr Pointer to the SGR attribute
356 * @return Pointer to the attribute value union
357 *
358 * @ingroup sgr
359 */
360GhosttySgrAttributeValue* ghostty_sgr_attribute_value(
361 GhosttySgrAttribute* attr);
362
363#ifdef __wasm__
364/**
365 * Allocate memory for an SGR attribute (WebAssembly only).
366 *
367 * This is a convenience function for WebAssembly environments to allocate
368 * memory for an SGR attribute structure that can be passed to ghostty_sgr_next.
369 *
370 * @return Pointer to the allocated attribute structure
371 *
372 * @ingroup wasm
373 */
374GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void);
375
376/**
377 * Free memory for an SGR attribute (WebAssembly only).
378 *
379 * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute.
380 *
381 * @param attr Pointer to the attribute structure to free
382 *
383 * @ingroup wasm
384 */
385void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr);
386#endif
387
388#ifdef __cplusplus
389}
390#endif
391
392/** @} */
393
394#endif /* GHOSTTY_VT_SGR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h created+159
...@@ -0,0 +1,159 @@
1/**
2 * @file wasm.h
3 *
4 * WebAssembly utility functions for libghostty-vt.
5 */
6
7#ifndef GHOSTTY_VT_WASM_H
8#define GHOSTTY_VT_WASM_H
9
10#ifdef __wasm__
11
12#include <stddef.h>
13#include <stdint.h>
14
15/** @defgroup wasm WebAssembly Utilities
16 *
17 * Convenience functions for allocating various types in WebAssembly builds.
18 * **These are only available the libghostty-vt wasm module.**
19 *
20 * Ghostty relies on pointers to various types for ABI compatibility, and
21 * creating those pointers in Wasm can be tedious. These functions provide
22 * a purely additive set of utilities that simplify memory management in
23 * Wasm environments without changing the core C library API.
24 *
25 * @note These functions always use the default allocator. If you need
26 * custom allocation strategies, you should allocate types manually using
27 * your custom allocator. This is a very rare use case in the WebAssembly
28 * world so these are optimized for simplicity.
29 *
30 * ## Example Usage
31 *
32 * Here's a simple example of using the Wasm utilities with the key encoder:
33 *
34 * @code
35 * const { exports } = wasmInstance;
36 * const view = new DataView(wasmMemory.buffer);
37 *
38 * // Create key encoder
39 * const encoderPtr = exports.ghostty_wasm_alloc_opaque();
40 * exports.ghostty_key_encoder_new(null, encoderPtr);
41 * const encoder = view.getUint32(encoder, true);
42 *
43 * // Configure encoder with Kitty protocol flags
44 * const flagsPtr = exports.ghostty_wasm_alloc_u8();
45 * view.setUint8(flagsPtr, 0x1F);
46 * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr);
47 *
48 * // Allocate output buffer and size pointer
49 * const bufferSize = 32;
50 * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize);
51 * const writtenPtr = exports.ghostty_wasm_alloc_usize();
52 *
53 * // Encode the key event
54 * exports.ghostty_key_encoder_encode(
55 * encoder, eventPtr, bufPtr, bufferSize, writtenPtr
56 * );
57 *
58 * // Read encoded output
59 * const bytesWritten = view.getUint32(writtenPtr, true);
60 * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten);
61 * @endcode
62 *
63 * @remark The code above is pretty ugly! This is the lowest level interface
64 * to the libghostty-vt Wasm module. In practice, this should be wrapped
65 * in a higher-level API that abstracts away all this.
66 *
67 * @{
68 */
69
70/**
71 * Allocate an opaque pointer. This can be used for any opaque pointer
72 * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc.
73 *
74 * @return Pointer to allocated opaque pointer, or NULL if allocation failed
75 * @ingroup wasm
76 */
77void** ghostty_wasm_alloc_opaque(void);
78
79/**
80 * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque().
81 *
82 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
83 * @ingroup wasm
84 */
85void ghostty_wasm_free_opaque(void **ptr);
86
87/**
88 * Allocate an array of uint8_t values.
89 *
90 * @param len Number of uint8_t elements to allocate
91 * @return Pointer to allocated array, or NULL if allocation failed
92 * @ingroup wasm
93 */
94uint8_t* ghostty_wasm_alloc_u8_array(size_t len);
95
96/**
97 * Free an array allocated by ghostty_wasm_alloc_u8_array().
98 *
99 * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
100 * @param len Length of the array (must match the length passed to alloc)
101 * @ingroup wasm
102 */
103void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len);
104
105/**
106 * Allocate an array of uint16_t values.
107 *
108 * @param len Number of uint16_t elements to allocate
109 * @return Pointer to allocated array, or NULL if allocation failed
110 * @ingroup wasm
111 */
112uint16_t* ghostty_wasm_alloc_u16_array(size_t len);
113
114/**
115 * Free an array allocated by ghostty_wasm_alloc_u16_array().
116 *
117 * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
118 * @param len Length of the array (must match the length passed to alloc)
119 * @ingroup wasm
120 */
121void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len);
122
123/**
124 * Allocate a single uint8_t value.
125 *
126 * @return Pointer to allocated uint8_t, or NULL if allocation failed
127 * @ingroup wasm
128 */
129uint8_t* ghostty_wasm_alloc_u8(void);
130
131/**
132 * Free a uint8_t allocated by ghostty_wasm_alloc_u8().
133 *
134 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
135 * @ingroup wasm
136 */
137void ghostty_wasm_free_u8(uint8_t *ptr);
138
139/**
140 * Allocate a single size_t value.
141 *
142 * @return Pointer to allocated size_t, or NULL if allocation failed
143 * @ingroup wasm
144 */
145size_t* ghostty_wasm_alloc_usize(void);
146
147/**
148 * Free a size_t allocated by ghostty_wasm_alloc_usize().
149 *
150 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
151 * @ingroup wasm
152 */
153void ghostty_wasm_free_usize(size_t *ptr);
154
155/** @} */
156
157#endif /* __wasm__ */
158
159#endif /* GHOSTTY_VT_WASM_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/module.modulemap created+7
...@@ -0,0 +1,7 @@
1// This makes Ghostty available to the XCode build for the macOS app.
2// We append "Kit" to it not to be cute, but because targets have to have
3// unique names and we use Ghostty for other things.
4module GhosttyKit {
5 umbrella header "ghostty.h"
6 export *
7}
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a created
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a differ
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h created+1218
...@@ -0,0 +1,1218 @@
1// Ghostty embedding API. The documentation for the embedding API is
2// only within the Zig source files that define the implementations. This
3// isn't meant to be a general purpose embedding API (yet) so there hasn't
4// been documentation or example work beyond that.
5//
6// The only consumer of this API is the macOS app, but the API is built to
7// be more general purpose.
8#ifndef GHOSTTY_H
9#define GHOSTTY_H
10
11#ifdef __cplusplus
12extern "C" {
13#endif
14
15#include <stdbool.h>
16#include <stddef.h>
17#include <stdint.h>
18
19#ifdef _MSC_VER
20#include <BaseTsd.h>
21typedef SSIZE_T ssize_t;
22#else
23#include <sys/types.h>
24#endif
25
26//-------------------------------------------------------------------
27// Macros
28
29#define GHOSTTY_SUCCESS 0
30
31// Symbol visibility for shared library builds. On Windows, functions
32// are exported from the DLL when building and imported when consuming.
33// On other platforms with GCC/Clang, functions are marked with default
34// visibility so they remain accessible when the library is built with
35// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC
36// before including this header to make this a no-op.
37#ifndef GHOSTTY_API
38#if defined(GHOSTTY_STATIC)
39 #define GHOSTTY_API
40#elif defined(_WIN32) || defined(_WIN64)
41 #ifdef GHOSTTY_BUILD_SHARED
42 #define GHOSTTY_API __declspec(dllexport)
43 #else
44 #define GHOSTTY_API __declspec(dllimport)
45 #endif
46#elif defined(__GNUC__) && __GNUC__ >= 4
47 #define GHOSTTY_API __attribute__((visibility("default")))
48#else
49 #define GHOSTTY_API
50#endif
51#endif
52
53//-------------------------------------------------------------------
54// Types
55
56// Opaque types
57typedef void* ghostty_app_t;
58typedef void* ghostty_config_t;
59typedef void* ghostty_surface_t;
60typedef void* ghostty_inspector_t;
61
62// All the types below are fully defined and must be kept in sync with
63// their Zig counterparts. Any changes to these types MUST have an associated
64// Zig change.
65typedef enum {
66 GHOSTTY_PLATFORM_INVALID,
67 GHOSTTY_PLATFORM_MACOS,
68 GHOSTTY_PLATFORM_IOS,
69} ghostty_platform_e;
70
71// Callback for custom I/O write handler.
72typedef void (*ghostty_surface_write_fn)(void* userdata,
73 const uint8_t* data,
74 size_t len);
75
76typedef enum {
77 GHOSTTY_CLIPBOARD_STANDARD,
78 GHOSTTY_CLIPBOARD_SELECTION,
79} ghostty_clipboard_e;
80
81typedef struct {
82 const char *mime;
83 const char *data;
84} ghostty_clipboard_content_s;
85
86typedef enum {
87 GHOSTTY_CLIPBOARD_REQUEST_PASTE,
88 GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
89 GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
90} ghostty_clipboard_request_e;
91
92typedef enum {
93 GHOSTTY_MOUSE_RELEASE,
94 GHOSTTY_MOUSE_PRESS,
95} ghostty_input_mouse_state_e;
96
97typedef enum {
98 GHOSTTY_MOUSE_UNKNOWN,
99 GHOSTTY_MOUSE_LEFT,
100 GHOSTTY_MOUSE_RIGHT,
101 GHOSTTY_MOUSE_MIDDLE,
102 GHOSTTY_MOUSE_FOUR,
103 GHOSTTY_MOUSE_FIVE,
104 GHOSTTY_MOUSE_SIX,
105 GHOSTTY_MOUSE_SEVEN,
106 GHOSTTY_MOUSE_EIGHT,
107 GHOSTTY_MOUSE_NINE,
108 GHOSTTY_MOUSE_TEN,
109 GHOSTTY_MOUSE_ELEVEN,
110} ghostty_input_mouse_button_e;
111
112typedef enum {
113 GHOSTTY_MOUSE_MOMENTUM_NONE,
114 GHOSTTY_MOUSE_MOMENTUM_BEGAN,
115 GHOSTTY_MOUSE_MOMENTUM_STATIONARY,
116 GHOSTTY_MOUSE_MOMENTUM_CHANGED,
117 GHOSTTY_MOUSE_MOMENTUM_ENDED,
118 GHOSTTY_MOUSE_MOMENTUM_CANCELLED,
119 GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN,
120} ghostty_input_mouse_momentum_e;
121
122typedef enum {
123 GHOSTTY_COLOR_SCHEME_LIGHT = 0,
124 GHOSTTY_COLOR_SCHEME_DARK = 1,
125} ghostty_color_scheme_e;
126
127// This is a packed struct (see src/input/mouse.zig) but the C standard
128// afaik doesn't let us reliably define packed structs so we build it up
129// from scratch.
130typedef int ghostty_input_scroll_mods_t;
131
132typedef enum {
133 GHOSTTY_MODS_NONE = 0,
134 GHOSTTY_MODS_SHIFT = 1 << 0,
135 GHOSTTY_MODS_CTRL = 1 << 1,
136 GHOSTTY_MODS_ALT = 1 << 2,
137 GHOSTTY_MODS_SUPER = 1 << 3,
138 GHOSTTY_MODS_CAPS = 1 << 4,
139 GHOSTTY_MODS_NUM = 1 << 5,
140 GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6,
141 GHOSTTY_MODS_CTRL_RIGHT = 1 << 7,
142 GHOSTTY_MODS_ALT_RIGHT = 1 << 8,
143 GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,
144} ghostty_input_mods_e;
145
146typedef enum {
147 GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,
148 GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,
149 GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,
150 GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,
151} ghostty_binding_flags_e;
152
153typedef enum {
154 GHOSTTY_ACTION_RELEASE,
155 GHOSTTY_ACTION_PRESS,
156 GHOSTTY_ACTION_REPEAT,
157} ghostty_input_action_e;
158
159// Based on: https://www.w3.org/TR/uievents-code/
160typedef enum {
161 GHOSTTY_KEY_UNIDENTIFIED,
162
163 // "Writing System Keys" § 3.1.1
164 GHOSTTY_KEY_BACKQUOTE,
165 GHOSTTY_KEY_BACKSLASH,
166 GHOSTTY_KEY_BRACKET_LEFT,
167 GHOSTTY_KEY_BRACKET_RIGHT,
168 GHOSTTY_KEY_COMMA,
169 GHOSTTY_KEY_DIGIT_0,
170 GHOSTTY_KEY_DIGIT_1,
171 GHOSTTY_KEY_DIGIT_2,
172 GHOSTTY_KEY_DIGIT_3,
173 GHOSTTY_KEY_DIGIT_4,
174 GHOSTTY_KEY_DIGIT_5,
175 GHOSTTY_KEY_DIGIT_6,
176 GHOSTTY_KEY_DIGIT_7,
177 GHOSTTY_KEY_DIGIT_8,
178 GHOSTTY_KEY_DIGIT_9,
179 GHOSTTY_KEY_EQUAL,
180 GHOSTTY_KEY_INTL_BACKSLASH,
181 GHOSTTY_KEY_INTL_RO,
182 GHOSTTY_KEY_INTL_YEN,
183 GHOSTTY_KEY_A,
184 GHOSTTY_KEY_B,
185 GHOSTTY_KEY_C,
186 GHOSTTY_KEY_D,
187 GHOSTTY_KEY_E,
188 GHOSTTY_KEY_F,
189 GHOSTTY_KEY_G,
190 GHOSTTY_KEY_H,
191 GHOSTTY_KEY_I,
192 GHOSTTY_KEY_J,
193 GHOSTTY_KEY_K,
194 GHOSTTY_KEY_L,
195 GHOSTTY_KEY_M,
196 GHOSTTY_KEY_N,
197 GHOSTTY_KEY_O,
198 GHOSTTY_KEY_P,
199 GHOSTTY_KEY_Q,
200 GHOSTTY_KEY_R,
201 GHOSTTY_KEY_S,
202 GHOSTTY_KEY_T,
203 GHOSTTY_KEY_U,
204 GHOSTTY_KEY_V,
205 GHOSTTY_KEY_W,
206 GHOSTTY_KEY_X,
207 GHOSTTY_KEY_Y,
208 GHOSTTY_KEY_Z,
209 GHOSTTY_KEY_MINUS,
210 GHOSTTY_KEY_PERIOD,
211 GHOSTTY_KEY_QUOTE,
212 GHOSTTY_KEY_SEMICOLON,
213 GHOSTTY_KEY_SLASH,
214
215 // "Functional Keys" § 3.1.2
216 GHOSTTY_KEY_ALT_LEFT,
217 GHOSTTY_KEY_ALT_RIGHT,
218 GHOSTTY_KEY_BACKSPACE,
219 GHOSTTY_KEY_CAPS_LOCK,
220 GHOSTTY_KEY_CONTEXT_MENU,
221 GHOSTTY_KEY_CONTROL_LEFT,
222 GHOSTTY_KEY_CONTROL_RIGHT,
223 GHOSTTY_KEY_ENTER,
224 GHOSTTY_KEY_META_LEFT,
225 GHOSTTY_KEY_META_RIGHT,
226 GHOSTTY_KEY_SHIFT_LEFT,
227 GHOSTTY_KEY_SHIFT_RIGHT,
228 GHOSTTY_KEY_SPACE,
229 GHOSTTY_KEY_TAB,
230 GHOSTTY_KEY_CONVERT,
231 GHOSTTY_KEY_KANA_MODE,
232 GHOSTTY_KEY_NON_CONVERT,
233
234 // "Control Pad Section" § 3.2
235 GHOSTTY_KEY_DELETE,
236 GHOSTTY_KEY_END,
237 GHOSTTY_KEY_HELP,
238 GHOSTTY_KEY_HOME,
239 GHOSTTY_KEY_INSERT,
240 GHOSTTY_KEY_PAGE_DOWN,
241 GHOSTTY_KEY_PAGE_UP,
242
243 // "Arrow Pad Section" § 3.3
244 GHOSTTY_KEY_ARROW_DOWN,
245 GHOSTTY_KEY_ARROW_LEFT,
246 GHOSTTY_KEY_ARROW_RIGHT,
247 GHOSTTY_KEY_ARROW_UP,
248
249 // "Numpad Section" § 3.4
250 GHOSTTY_KEY_NUM_LOCK,
251 GHOSTTY_KEY_NUMPAD_0,
252 GHOSTTY_KEY_NUMPAD_1,
253 GHOSTTY_KEY_NUMPAD_2,
254 GHOSTTY_KEY_NUMPAD_3,
255 GHOSTTY_KEY_NUMPAD_4,
256 GHOSTTY_KEY_NUMPAD_5,
257 GHOSTTY_KEY_NUMPAD_6,
258 GHOSTTY_KEY_NUMPAD_7,
259 GHOSTTY_KEY_NUMPAD_8,
260 GHOSTTY_KEY_NUMPAD_9,
261 GHOSTTY_KEY_NUMPAD_ADD,
262 GHOSTTY_KEY_NUMPAD_BACKSPACE,
263 GHOSTTY_KEY_NUMPAD_CLEAR,
264 GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
265 GHOSTTY_KEY_NUMPAD_COMMA,
266 GHOSTTY_KEY_NUMPAD_DECIMAL,
267 GHOSTTY_KEY_NUMPAD_DIVIDE,
268 GHOSTTY_KEY_NUMPAD_ENTER,
269 GHOSTTY_KEY_NUMPAD_EQUAL,
270 GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
271 GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
272 GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
273 GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
274 GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
275 GHOSTTY_KEY_NUMPAD_MULTIPLY,
276 GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
277 GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
278 GHOSTTY_KEY_NUMPAD_SUBTRACT,
279 GHOSTTY_KEY_NUMPAD_SEPARATOR,
280 GHOSTTY_KEY_NUMPAD_UP,
281 GHOSTTY_KEY_NUMPAD_DOWN,
282 GHOSTTY_KEY_NUMPAD_RIGHT,
283 GHOSTTY_KEY_NUMPAD_LEFT,
284 GHOSTTY_KEY_NUMPAD_BEGIN,
285 GHOSTTY_KEY_NUMPAD_HOME,
286 GHOSTTY_KEY_NUMPAD_END,
287 GHOSTTY_KEY_NUMPAD_INSERT,
288 GHOSTTY_KEY_NUMPAD_DELETE,
289 GHOSTTY_KEY_NUMPAD_PAGE_UP,
290 GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
291
292 // "Function Section" § 3.5
293 GHOSTTY_KEY_ESCAPE,
294 GHOSTTY_KEY_F1,
295 GHOSTTY_KEY_F2,
296 GHOSTTY_KEY_F3,
297 GHOSTTY_KEY_F4,
298 GHOSTTY_KEY_F5,
299 GHOSTTY_KEY_F6,
300 GHOSTTY_KEY_F7,
301 GHOSTTY_KEY_F8,
302 GHOSTTY_KEY_F9,
303 GHOSTTY_KEY_F10,
304 GHOSTTY_KEY_F11,
305 GHOSTTY_KEY_F12,
306 GHOSTTY_KEY_F13,
307 GHOSTTY_KEY_F14,
308 GHOSTTY_KEY_F15,
309 GHOSTTY_KEY_F16,
310 GHOSTTY_KEY_F17,
311 GHOSTTY_KEY_F18,
312 GHOSTTY_KEY_F19,
313 GHOSTTY_KEY_F20,
314 GHOSTTY_KEY_F21,
315 GHOSTTY_KEY_F22,
316 GHOSTTY_KEY_F23,
317 GHOSTTY_KEY_F24,
318 GHOSTTY_KEY_F25,
319 GHOSTTY_KEY_FN,
320 GHOSTTY_KEY_FN_LOCK,
321 GHOSTTY_KEY_PRINT_SCREEN,
322 GHOSTTY_KEY_SCROLL_LOCK,
323 GHOSTTY_KEY_PAUSE,
324
325 // "Media Keys" § 3.6
326 GHOSTTY_KEY_BROWSER_BACK,
327 GHOSTTY_KEY_BROWSER_FAVORITES,
328 GHOSTTY_KEY_BROWSER_FORWARD,
329 GHOSTTY_KEY_BROWSER_HOME,
330 GHOSTTY_KEY_BROWSER_REFRESH,
331 GHOSTTY_KEY_BROWSER_SEARCH,
332 GHOSTTY_KEY_BROWSER_STOP,
333 GHOSTTY_KEY_EJECT,
334 GHOSTTY_KEY_LAUNCH_APP_1,
335 GHOSTTY_KEY_LAUNCH_APP_2,
336 GHOSTTY_KEY_LAUNCH_MAIL,
337 GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
338 GHOSTTY_KEY_MEDIA_SELECT,
339 GHOSTTY_KEY_MEDIA_STOP,
340 GHOSTTY_KEY_MEDIA_TRACK_NEXT,
341 GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
342 GHOSTTY_KEY_POWER,
343 GHOSTTY_KEY_SLEEP,
344 GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
345 GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
346 GHOSTTY_KEY_AUDIO_VOLUME_UP,
347 GHOSTTY_KEY_WAKE_UP,
348
349 // "Legacy, Non-standard, and Special Keys" § 3.7
350 GHOSTTY_KEY_COPY,
351 GHOSTTY_KEY_CUT,
352 GHOSTTY_KEY_PASTE,
353} ghostty_input_key_e;
354
355typedef struct {
356 ghostty_input_action_e action;
357 ghostty_input_mods_e mods;
358 ghostty_input_mods_e consumed_mods;
359 uint32_t keycode;
360 const char* text;
361 uint32_t unshifted_codepoint;
362 bool composing;
363} ghostty_input_key_s;
364
365typedef enum {
366 GHOSTTY_TRIGGER_PHYSICAL,
367 GHOSTTY_TRIGGER_UNICODE,
368 GHOSTTY_TRIGGER_CATCH_ALL,
369} ghostty_input_trigger_tag_e;
370
371typedef union {
372 ghostty_input_key_e translated;
373 ghostty_input_key_e physical;
374 uint32_t unicode;
375 // catch_all has no payload
376} ghostty_input_trigger_key_u;
377
378typedef struct {
379 ghostty_input_trigger_tag_e tag;
380 ghostty_input_trigger_key_u key;
381 ghostty_input_mods_e mods;
382} ghostty_input_trigger_s;
383
384typedef struct {
385 const char* action_key;
386 const char* action;
387 const char* title;
388 const char* description;
389} ghostty_command_s;
390
391typedef enum {
392 GHOSTTY_BUILD_MODE_DEBUG,
393 GHOSTTY_BUILD_MODE_RELEASE_SAFE,
394 GHOSTTY_BUILD_MODE_RELEASE_FAST,
395 GHOSTTY_BUILD_MODE_RELEASE_SMALL,
396} ghostty_build_mode_e;
397
398typedef struct {
399 ghostty_build_mode_e build_mode;
400 const char* version;
401 uintptr_t version_len;
402} ghostty_info_s;
403
404typedef struct {
405 const char* message;
406} ghostty_diagnostic_s;
407
408typedef struct {
409 const char* ptr;
410 uintptr_t len;
411 bool sentinel;
412} ghostty_string_s;
413
414typedef struct {
415 double tl_px_x;
416 double tl_px_y;
417 uint32_t offset_start;
418 uint32_t offset_len;
419 const char* text;
420 uintptr_t text_len;
421} ghostty_text_s;
422
423typedef enum {
424 GHOSTTY_POINT_ACTIVE,
425 GHOSTTY_POINT_VIEWPORT,
426 GHOSTTY_POINT_SCREEN,
427 GHOSTTY_POINT_SURFACE,
428} ghostty_point_tag_e;
429
430typedef enum {
431 GHOSTTY_POINT_COORD_EXACT,
432 GHOSTTY_POINT_COORD_TOP_LEFT,
433 GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
434} ghostty_point_coord_e;
435
436typedef struct {
437 ghostty_point_tag_e tag;
438 ghostty_point_coord_e coord;
439 uint32_t x;
440 uint32_t y;
441} ghostty_point_s;
442
443typedef struct {
444 ghostty_point_s top_left;
445 ghostty_point_s bottom_right;
446 bool rectangle;
447} ghostty_selection_s;
448
449typedef struct {
450 const char* key;
451 const char* value;
452} ghostty_env_var_s;
453
454typedef struct {
455 void* nsview;
456} ghostty_platform_macos_s;
457
458typedef struct {
459 void* uiview;
460} ghostty_platform_ios_s;
461
462typedef union {
463 ghostty_platform_macos_s macos;
464 ghostty_platform_ios_s ios;
465} ghostty_platform_u;
466
467typedef enum {
468 GHOSTTY_SURFACE_CONTEXT_WINDOW = 0,
469 GHOSTTY_SURFACE_CONTEXT_TAB = 1,
470 GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
471} ghostty_surface_context_e;
472
473typedef struct {
474 ghostty_platform_e platform_tag;
475 ghostty_platform_u platform;
476 void* userdata;
477 double scale_factor;
478 float font_size;
479 const char* working_directory;
480 const char* command;
481 ghostty_env_var_s* env_vars;
482 size_t env_var_count;
483 const char* initial_input;
484 bool wait_after_command;
485 ghostty_surface_context_e context;
486 bool use_custom_io;
487} ghostty_surface_config_s;
488
489typedef struct {
490 uint16_t columns;
491 uint16_t rows;
492 uint32_t width_px;
493 uint32_t height_px;
494 uint32_t cell_width_px;
495 uint32_t cell_height_px;
496} ghostty_surface_size_s;
497
498// Config types
499
500// config.Path
501typedef struct {
502 const char* path;
503 bool optional;
504} ghostty_config_path_s;
505
506// config.Color
507typedef struct {
508 uint8_t r;
509 uint8_t g;
510 uint8_t b;
511} ghostty_config_color_s;
512
513// config.ColorList
514typedef struct {
515 const ghostty_config_color_s* colors;
516 size_t len;
517} ghostty_config_color_list_s;
518
519// config.RepeatableCommand
520typedef struct {
521 const ghostty_command_s* commands;
522 size_t len;
523} ghostty_config_command_list_s;
524
525// config.Palette
526typedef struct {
527 ghostty_config_color_s colors[256];
528} ghostty_config_palette_s;
529
530// config.QuickTerminalSize
531typedef enum {
532 GHOSTTY_QUICK_TERMINAL_SIZE_NONE,
533 GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE,
534 GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS,
535} ghostty_quick_terminal_size_tag_e;
536
537typedef union {
538 float percentage;
539 uint32_t pixels;
540} ghostty_quick_terminal_size_value_u;
541
542typedef struct {
543 ghostty_quick_terminal_size_tag_e tag;
544 ghostty_quick_terminal_size_value_u value;
545} ghostty_quick_terminal_size_s;
546
547typedef struct {
548 ghostty_quick_terminal_size_s primary;
549 ghostty_quick_terminal_size_s secondary;
550} ghostty_config_quick_terminal_size_s;
551
552// config.Fullscreen
553typedef enum {
554 GHOSTTY_CONFIG_FULLSCREEN_FALSE,
555 GHOSTTY_CONFIG_FULLSCREEN_TRUE,
556 GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE,
557 GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU,
558 GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH,
559} ghostty_config_fullscreen_e;
560
561// apprt.Target.Key
562typedef enum {
563 GHOSTTY_TARGET_APP,
564 GHOSTTY_TARGET_SURFACE,
565} ghostty_target_tag_e;
566
567typedef union {
568 ghostty_surface_t surface;
569} ghostty_target_u;
570
571typedef struct {
572 ghostty_target_tag_e tag;
573 ghostty_target_u target;
574} ghostty_target_s;
575
576// apprt.action.SplitDirection
577typedef enum {
578 GHOSTTY_SPLIT_DIRECTION_RIGHT,
579 GHOSTTY_SPLIT_DIRECTION_DOWN,
580 GHOSTTY_SPLIT_DIRECTION_LEFT,
581 GHOSTTY_SPLIT_DIRECTION_UP,
582} ghostty_action_split_direction_e;
583
584// apprt.action.GotoSplit
585typedef enum {
586 GHOSTTY_GOTO_SPLIT_PREVIOUS,
587 GHOSTTY_GOTO_SPLIT_NEXT,
588 GHOSTTY_GOTO_SPLIT_UP,
589 GHOSTTY_GOTO_SPLIT_LEFT,
590 GHOSTTY_GOTO_SPLIT_DOWN,
591 GHOSTTY_GOTO_SPLIT_RIGHT,
592} ghostty_action_goto_split_e;
593
594// apprt.action.GotoWindow
595typedef enum {
596 GHOSTTY_GOTO_WINDOW_PREVIOUS,
597 GHOSTTY_GOTO_WINDOW_NEXT,
598} ghostty_action_goto_window_e;
599
600// apprt.action.ResizeSplit.Direction
601typedef enum {
602 GHOSTTY_RESIZE_SPLIT_UP,
603 GHOSTTY_RESIZE_SPLIT_DOWN,
604 GHOSTTY_RESIZE_SPLIT_LEFT,
605 GHOSTTY_RESIZE_SPLIT_RIGHT,
606} ghostty_action_resize_split_direction_e;
607
608// apprt.action.ResizeSplit
609typedef struct {
610 uint16_t amount;
611 ghostty_action_resize_split_direction_e direction;
612} ghostty_action_resize_split_s;
613
614// apprt.action.MoveTab
615typedef struct {
616 ssize_t amount;
617} ghostty_action_move_tab_s;
618
619// apprt.action.GotoTab
620typedef enum {
621 GHOSTTY_GOTO_TAB_PREVIOUS = -1,
622 GHOSTTY_GOTO_TAB_NEXT = -2,
623 GHOSTTY_GOTO_TAB_LAST = -3,
624} ghostty_action_goto_tab_e;
625
626// apprt.action.Fullscreen
627typedef enum {
628 GHOSTTY_FULLSCREEN_NATIVE,
629 GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE,
630 GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU,
631 GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH,
632} ghostty_action_fullscreen_e;
633
634// apprt.action.FloatWindow
635typedef enum {
636 GHOSTTY_FLOAT_WINDOW_ON,
637 GHOSTTY_FLOAT_WINDOW_OFF,
638 GHOSTTY_FLOAT_WINDOW_TOGGLE,
639} ghostty_action_float_window_e;
640
641// apprt.action.SecureInput
642typedef enum {
643 GHOSTTY_SECURE_INPUT_ON,
644 GHOSTTY_SECURE_INPUT_OFF,
645 GHOSTTY_SECURE_INPUT_TOGGLE,
646} ghostty_action_secure_input_e;
647
648// apprt.action.Inspector
649typedef enum {
650 GHOSTTY_INSPECTOR_TOGGLE,
651 GHOSTTY_INSPECTOR_SHOW,
652 GHOSTTY_INSPECTOR_HIDE,
653} ghostty_action_inspector_e;
654
655// apprt.action.QuitTimer
656typedef enum {
657 GHOSTTY_QUIT_TIMER_START,
658 GHOSTTY_QUIT_TIMER_STOP,
659} ghostty_action_quit_timer_e;
660
661// apprt.action.Readonly
662typedef enum {
663 GHOSTTY_READONLY_OFF,
664 GHOSTTY_READONLY_ON,
665} ghostty_action_readonly_e;
666
667// apprt.action.DesktopNotification.C
668typedef struct {
669 const char* title;
670 const char* body;
671} ghostty_action_desktop_notification_s;
672
673// apprt.action.SetTitle.C
674typedef struct {
675 const char* title;
676} ghostty_action_set_title_s;
677
678// apprt.action.PromptTitle
679typedef enum {
680 GHOSTTY_PROMPT_TITLE_SURFACE,
681 GHOSTTY_PROMPT_TITLE_TAB,
682} ghostty_action_prompt_title_e;
683
684// apprt.action.Pwd.C
685typedef struct {
686 const char* pwd;
687} ghostty_action_pwd_s;
688
689// terminal.MouseShape
690typedef enum {
691 GHOSTTY_MOUSE_SHAPE_DEFAULT,
692 GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,
693 GHOSTTY_MOUSE_SHAPE_HELP,
694 GHOSTTY_MOUSE_SHAPE_POINTER,
695 GHOSTTY_MOUSE_SHAPE_PROGRESS,
696 GHOSTTY_MOUSE_SHAPE_WAIT,
697 GHOSTTY_MOUSE_SHAPE_CELL,
698 GHOSTTY_MOUSE_SHAPE_CROSSHAIR,
699 GHOSTTY_MOUSE_SHAPE_TEXT,
700 GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,
701 GHOSTTY_MOUSE_SHAPE_ALIAS,
702 GHOSTTY_MOUSE_SHAPE_COPY,
703 GHOSTTY_MOUSE_SHAPE_MOVE,
704 GHOSTTY_MOUSE_SHAPE_NO_DROP,
705 GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,
706 GHOSTTY_MOUSE_SHAPE_GRAB,
707 GHOSTTY_MOUSE_SHAPE_GRABBING,
708 GHOSTTY_MOUSE_SHAPE_ALL_SCROLL,
709 GHOSTTY_MOUSE_SHAPE_COL_RESIZE,
710 GHOSTTY_MOUSE_SHAPE_ROW_RESIZE,
711 GHOSTTY_MOUSE_SHAPE_N_RESIZE,
712 GHOSTTY_MOUSE_SHAPE_E_RESIZE,
713 GHOSTTY_MOUSE_SHAPE_S_RESIZE,
714 GHOSTTY_MOUSE_SHAPE_W_RESIZE,
715 GHOSTTY_MOUSE_SHAPE_NE_RESIZE,
716 GHOSTTY_MOUSE_SHAPE_NW_RESIZE,
717 GHOSTTY_MOUSE_SHAPE_SE_RESIZE,
718 GHOSTTY_MOUSE_SHAPE_SW_RESIZE,
719 GHOSTTY_MOUSE_SHAPE_EW_RESIZE,
720 GHOSTTY_MOUSE_SHAPE_NS_RESIZE,
721 GHOSTTY_MOUSE_SHAPE_NESW_RESIZE,
722 GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE,
723 GHOSTTY_MOUSE_SHAPE_ZOOM_IN,
724 GHOSTTY_MOUSE_SHAPE_ZOOM_OUT,
725} ghostty_action_mouse_shape_e;
726
727// apprt.action.MouseVisibility
728typedef enum {
729 GHOSTTY_MOUSE_VISIBLE,
730 GHOSTTY_MOUSE_HIDDEN,
731} ghostty_action_mouse_visibility_e;
732
733// apprt.action.MouseOverLink
734typedef struct {
735 const char* url;
736 size_t len;
737} ghostty_action_mouse_over_link_s;
738
739// apprt.action.SizeLimit
740typedef struct {
741 uint32_t min_width;
742 uint32_t min_height;
743 uint32_t max_width;
744 uint32_t max_height;
745} ghostty_action_size_limit_s;
746
747// apprt.action.InitialSize
748typedef struct {
749 uint32_t width;
750 uint32_t height;
751} ghostty_action_initial_size_s;
752
753// apprt.action.CellSize
754typedef struct {
755 uint32_t width;
756 uint32_t height;
757} ghostty_action_cell_size_s;
758
759// renderer.Health
760typedef enum {
761 GHOSTTY_RENDERER_HEALTH_HEALTHY,
762 GHOSTTY_RENDERER_HEALTH_UNHEALTHY,
763} ghostty_action_renderer_health_e;
764
765// apprt.action.KeySequence
766typedef struct {
767 bool active;
768 ghostty_input_trigger_s trigger;
769} ghostty_action_key_sequence_s;
770
771// apprt.action.KeyTable.Tag
772typedef enum {
773 GHOSTTY_KEY_TABLE_ACTIVATE,
774 GHOSTTY_KEY_TABLE_DEACTIVATE,
775 GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
776} ghostty_action_key_table_tag_e;
777
778// apprt.action.KeyTable.CValue
779typedef union {
780 struct {
781 const char *name;
782 size_t len;
783 } activate;
784} ghostty_action_key_table_u;
785
786// apprt.action.KeyTable.C
787typedef struct {
788 ghostty_action_key_table_tag_e tag;
789 ghostty_action_key_table_u value;
790} ghostty_action_key_table_s;
791
792// apprt.action.ColorKind
793typedef enum {
794 GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
795 GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2,
796 GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3,
797} ghostty_action_color_kind_e;
798
799// apprt.action.ColorChange
800typedef struct {
801 ghostty_action_color_kind_e kind;
802 uint8_t r;
803 uint8_t g;
804 uint8_t b;
805} ghostty_action_color_change_s;
806
807// apprt.action.ConfigChange
808typedef struct {
809 ghostty_config_t config;
810} ghostty_action_config_change_s;
811
812// apprt.action.ReloadConfig
813typedef struct {
814 bool soft;
815} ghostty_action_reload_config_s;
816
817// apprt.action.OpenUrlKind
818typedef enum {
819 GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,
820 GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
821 GHOSTTY_ACTION_OPEN_URL_KIND_HTML,
822} ghostty_action_open_url_kind_e;
823
824// apprt.action.OpenUrl.C
825typedef struct {
826 ghostty_action_open_url_kind_e kind;
827 const char* url;
828 uintptr_t len;
829} ghostty_action_open_url_s;
830
831// apprt.action.CloseTabMode
832typedef enum {
833 GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,
834 GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER,
835 GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT,
836} ghostty_action_close_tab_mode_e;
837
838// apprt.surface.Message.ChildExited
839typedef struct {
840 uint32_t exit_code;
841 uint64_t timetime_ms;
842} ghostty_surface_message_childexited_s;
843
844// terminal.osc.Command.ProgressReport.State
845typedef enum {
846 GHOSTTY_PROGRESS_STATE_REMOVE,
847 GHOSTTY_PROGRESS_STATE_SET,
848 GHOSTTY_PROGRESS_STATE_ERROR,
849 GHOSTTY_PROGRESS_STATE_INDETERMINATE,
850 GHOSTTY_PROGRESS_STATE_PAUSE,
851} ghostty_action_progress_report_state_e;
852
853// terminal.osc.Command.ProgressReport.C
854typedef struct {
855 ghostty_action_progress_report_state_e state;
856 // -1 if no progress was reported, otherwise 0-100 indicating percent
857 // completeness.
858 int8_t progress;
859} ghostty_action_progress_report_s;
860
861// apprt.action.CommandFinished.C
862typedef struct {
863 // -1 if no exit code was reported, otherwise 0-255
864 int16_t exit_code;
865 // number of nanoseconds that command was running for
866 uint64_t duration;
867} ghostty_action_command_finished_s;
868
869// apprt.action.StartSearch.C
870typedef struct {
871 const char* needle;
872} ghostty_action_start_search_s;
873
874// apprt.action.SearchTotal
875typedef struct {
876 ssize_t total;
877} ghostty_action_search_total_s;
878
879// apprt.action.SearchSelected
880typedef struct {
881 ssize_t selected;
882} ghostty_action_search_selected_s;
883
884// terminal.Scrollbar
885typedef struct {
886 uint64_t total;
887 uint64_t offset;
888 uint64_t len;
889} ghostty_action_scrollbar_s;
890
891// apprt.Action.Key
892typedef enum {
893 GHOSTTY_ACTION_QUIT,
894 GHOSTTY_ACTION_NEW_WINDOW,
895 GHOSTTY_ACTION_NEW_TAB,
896 GHOSTTY_ACTION_CLOSE_TAB,
897 GHOSTTY_ACTION_NEW_SPLIT,
898 GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
899 GHOSTTY_ACTION_TOGGLE_MAXIMIZE,
900 GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
901 GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
902 GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,
903 GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
904 GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,
905 GHOSTTY_ACTION_TOGGLE_VISIBILITY,
906 GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY,
907 GHOSTTY_ACTION_MOVE_TAB,
908 GHOSTTY_ACTION_GOTO_TAB,
909 GHOSTTY_ACTION_GOTO_SPLIT,
910 GHOSTTY_ACTION_GOTO_WINDOW,
911 GHOSTTY_ACTION_RESIZE_SPLIT,
912 GHOSTTY_ACTION_EQUALIZE_SPLITS,
913 GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM,
914 GHOSTTY_ACTION_PRESENT_TERMINAL,
915 GHOSTTY_ACTION_SIZE_LIMIT,
916 GHOSTTY_ACTION_RESET_WINDOW_SIZE,
917 GHOSTTY_ACTION_INITIAL_SIZE,
918 GHOSTTY_ACTION_CELL_SIZE,
919 GHOSTTY_ACTION_SCROLLBAR,
920 GHOSTTY_ACTION_RENDER,
921 GHOSTTY_ACTION_INSPECTOR,
922 GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
923 GHOSTTY_ACTION_RENDER_INSPECTOR,
924 GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
925 GHOSTTY_ACTION_SET_TITLE,
926 GHOSTTY_ACTION_SET_TAB_TITLE,
927 GHOSTTY_ACTION_PROMPT_TITLE,
928 GHOSTTY_ACTION_PWD,
929 GHOSTTY_ACTION_MOUSE_SHAPE,
930 GHOSTTY_ACTION_MOUSE_VISIBILITY,
931 GHOSTTY_ACTION_MOUSE_OVER_LINK,
932 GHOSTTY_ACTION_RENDERER_HEALTH,
933 GHOSTTY_ACTION_OPEN_CONFIG,
934 GHOSTTY_ACTION_QUIT_TIMER,
935 GHOSTTY_ACTION_FLOAT_WINDOW,
936 GHOSTTY_ACTION_SECURE_INPUT,
937 GHOSTTY_ACTION_KEY_SEQUENCE,
938 GHOSTTY_ACTION_KEY_TABLE,
939 GHOSTTY_ACTION_COLOR_CHANGE,
940 GHOSTTY_ACTION_RELOAD_CONFIG,
941 GHOSTTY_ACTION_CONFIG_CHANGE,
942 GHOSTTY_ACTION_CLOSE_WINDOW,
943 GHOSTTY_ACTION_RING_BELL,
944 GHOSTTY_ACTION_UNDO,
945 GHOSTTY_ACTION_REDO,
946 GHOSTTY_ACTION_CHECK_FOR_UPDATES,
947 GHOSTTY_ACTION_OPEN_URL,
948 GHOSTTY_ACTION_SHOW_CHILD_EXITED,
949 GHOSTTY_ACTION_PROGRESS_REPORT,
950 GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD,
951 GHOSTTY_ACTION_COMMAND_FINISHED,
952 GHOSTTY_ACTION_START_SEARCH,
953 GHOSTTY_ACTION_END_SEARCH,
954 GHOSTTY_ACTION_SEARCH_TOTAL,
955 GHOSTTY_ACTION_SEARCH_SELECTED,
956 GHOSTTY_ACTION_READONLY,
957 GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD,
958} ghostty_action_tag_e;
959
960typedef union {
961 ghostty_action_split_direction_e new_split;
962 ghostty_action_fullscreen_e toggle_fullscreen;
963 ghostty_action_move_tab_s move_tab;
964 ghostty_action_goto_tab_e goto_tab;
965 ghostty_action_goto_split_e goto_split;
966 ghostty_action_goto_window_e goto_window;
967 ghostty_action_resize_split_s resize_split;
968 ghostty_action_size_limit_s size_limit;
969 ghostty_action_initial_size_s initial_size;
970 ghostty_action_cell_size_s cell_size;
971 ghostty_action_scrollbar_s scrollbar;
972 ghostty_action_inspector_e inspector;
973 ghostty_action_desktop_notification_s desktop_notification;
974 ghostty_action_set_title_s set_title;
975 ghostty_action_set_title_s set_tab_title;
976 ghostty_action_prompt_title_e prompt_title;
977 ghostty_action_pwd_s pwd;
978 ghostty_action_mouse_shape_e mouse_shape;
979 ghostty_action_mouse_visibility_e mouse_visibility;
980 ghostty_action_mouse_over_link_s mouse_over_link;
981 ghostty_action_renderer_health_e renderer_health;
982 ghostty_action_quit_timer_e quit_timer;
983 ghostty_action_float_window_e float_window;
984 ghostty_action_secure_input_e secure_input;
985 ghostty_action_key_sequence_s key_sequence;
986 ghostty_action_key_table_s key_table;
987 ghostty_action_color_change_s color_change;
988 ghostty_action_reload_config_s reload_config;
989 ghostty_action_config_change_s config_change;
990 ghostty_action_open_url_s open_url;
991 ghostty_action_close_tab_mode_e close_tab_mode;
992 ghostty_surface_message_childexited_s child_exited;
993 ghostty_action_progress_report_s progress_report;
994 ghostty_action_command_finished_s command_finished;
995 ghostty_action_start_search_s start_search;
996 ghostty_action_search_total_s search_total;
997 ghostty_action_search_selected_s search_selected;
998 ghostty_action_readonly_e readonly;
999} ghostty_action_u;
1000
1001typedef struct {
1002 ghostty_action_tag_e tag;
1003 ghostty_action_u action;
1004} ghostty_action_s;
1005
1006typedef void (*ghostty_runtime_wakeup_cb)(void*);
1007typedef bool (*ghostty_runtime_read_clipboard_cb)(void*,
1008 ghostty_clipboard_e,
1009 void*);
1010typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
1011 void*,
1012 const char*,
1013 void*,
1014 ghostty_clipboard_request_e);
1015typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
1016 ghostty_clipboard_e,
1017 const ghostty_clipboard_content_s*,
1018 size_t,
1019 bool);
1020typedef void (*ghostty_runtime_close_surface_cb)(void*, bool);
1021typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t,
1022 ghostty_target_s,
1023 ghostty_action_s);
1024
1025typedef struct {
1026 void* userdata;
1027 bool supports_selection_clipboard;
1028 ghostty_runtime_wakeup_cb wakeup_cb;
1029 ghostty_runtime_action_cb action_cb;
1030 ghostty_runtime_read_clipboard_cb read_clipboard_cb;
1031 ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb;
1032 ghostty_runtime_write_clipboard_cb write_clipboard_cb;
1033 ghostty_runtime_close_surface_cb close_surface_cb;
1034} ghostty_runtime_config_s;
1035
1036// apprt.ipc.Target.Key
1037typedef enum {
1038 GHOSTTY_IPC_TARGET_CLASS,
1039 GHOSTTY_IPC_TARGET_DETECT,
1040} ghostty_ipc_target_tag_e;
1041
1042typedef union {
1043 char *klass;
1044} ghostty_ipc_target_u;
1045
1046typedef struct {
1047 ghostty_ipc_target_tag_e tag;
1048 ghostty_ipc_target_u target;
1049} chostty_ipc_target_s;
1050
1051// apprt.ipc.Action.NewWindow
1052typedef struct {
1053 // This should be a null terminated list of strings.
1054 const char **arguments;
1055} ghostty_ipc_action_new_window_s;
1056
1057typedef union {
1058 ghostty_ipc_action_new_window_s new_window;
1059} ghostty_ipc_action_u;
1060
1061// apprt.ipc.Action.Key
1062typedef enum {
1063 GHOSTTY_IPC_ACTION_NEW_WINDOW,
1064} ghostty_ipc_action_tag_e;
1065
1066//-------------------------------------------------------------------
1067// Published API
1068
1069GHOSTTY_API int ghostty_init(uintptr_t, char**);
1070GHOSTTY_API void ghostty_cli_try_action(void);
1071GHOSTTY_API ghostty_info_s ghostty_info(void);
1072GHOSTTY_API const char* ghostty_translate(const char*);
1073GHOSTTY_API void ghostty_string_free(ghostty_string_s);
1074
1075GHOSTTY_API ghostty_config_t ghostty_config_new();
1076GHOSTTY_API void ghostty_config_free(ghostty_config_t);
1077GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t);
1078GHOSTTY_API void ghostty_config_load_cli_args(ghostty_config_t);
1079GHOSTTY_API void ghostty_config_load_file(ghostty_config_t, const char*);
1080GHOSTTY_API void ghostty_config_load_default_files(ghostty_config_t);
1081GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t);
1082GHOSTTY_API void ghostty_config_finalize(ghostty_config_t);
1083GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t);
1084GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t,
1085 const char*,
1086 uintptr_t);
1087GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t);
1088GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t);
1089GHOSTTY_API ghostty_string_s ghostty_config_open_path(void);
1090
1091GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
1092 ghostty_config_t);
1093GHOSTTY_API void ghostty_app_free(ghostty_app_t);
1094GHOSTTY_API void ghostty_app_tick(ghostty_app_t);
1095GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t);
1096GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool);
1097GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
1098GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s);
1099GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t);
1100GHOSTTY_API void ghostty_app_open_config(ghostty_app_t);
1101GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t);
1102GHOSTTY_API bool ghostty_app_needs_confirm_quit(ghostty_app_t);
1103GHOSTTY_API bool ghostty_app_has_global_keybinds(ghostty_app_t);
1104GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);
1105
1106GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new();
1107
1108GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t,
1109 const ghostty_surface_config_s*);
1110GHOSTTY_API void ghostty_surface_free(ghostty_surface_t);
1111GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t);
1112GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t);
1113GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);
1114GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
1115GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
1116GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t);
1117GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t);
1118GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t);
1119GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t);
1120GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t,
1121 ghostty_surface_write_fn,
1122 void*);
1123GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
1124GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool);
1125GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool);
1126GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t);
1127GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t);
1128GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t);
1129GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t);
1130GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t,
1131 ghostty_color_scheme_e);
1132GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
1133 ghostty_input_mods_e);
1134GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
1135GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t,
1136 ghostty_input_key_s,
1137 ghostty_binding_flags_e*);
1138GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
1139GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
1140GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t);
1141GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t,
1142 ghostty_input_mouse_state_e,
1143 ghostty_input_mouse_button_e,
1144 ghostty_input_mods_e);
1145GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t,
1146 double,
1147 double,
1148 ghostty_input_mods_e);
1149GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t,
1150 double,
1151 double,
1152 ghostty_input_scroll_mods_t);
1153GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);
1154GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*);
1155GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t);
1156GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e);
1157GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t,
1158 ghostty_action_goto_split_e);
1159GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t,
1160 ghostty_action_resize_split_direction_e,
1161 uint16_t);
1162GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t);
1163GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t);
1164GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
1165 const char*,
1166 void*,
1167 bool);
1168GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t);
1169GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
1170GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t,
1171 ghostty_selection_s,
1172 ghostty_text_s*);
1173GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
1174
1175#ifdef __APPLE__
1176GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
1177GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t);
1178GHOSTTY_API bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*);
1179#endif
1180
1181GHOSTTY_API ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);
1182GHOSTTY_API void ghostty_inspector_free(ghostty_surface_t);
1183GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool);
1184GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double);
1185GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t);
1186GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t,
1187 ghostty_input_mouse_state_e,
1188 ghostty_input_mouse_button_e,
1189 ghostty_input_mods_e);
1190GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double);
1191GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t,
1192 double,
1193 double,
1194 ghostty_input_scroll_mods_t);
1195GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t,
1196 ghostty_input_action_e,
1197 ghostty_input_key_e,
1198 ghostty_input_mods_e);
1199GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*);
1200
1201#ifdef __APPLE__
1202GHOSTTY_API bool ghostty_inspector_metal_init(ghostty_inspector_t, void*);
1203GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*);
1204GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t);
1205#endif
1206
1207// APIs I'd like to get rid of eventually but are still needed for now.
1208// Don't use these unless you know what you're doing.
1209GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*);
1210
1211// Benchmark API, if available.
1212GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*);
1213
1214#ifdef __cplusplus
1215}
1216#endif
1217
1218#endif /* GHOSTTY_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h created+87
...@@ -0,0 +1,87 @@
1/**
2 * @file vt.h
3 *
4 * libghostty-vt - Virtual terminal emulator library
5 *
6 * This library provides functionality for parsing and handling terminal
7 * escape sequences as well as maintaining terminal state such as styles,
8 * cursor position, screen, scrollback, and more.
9 *
10 * WARNING: This is an incomplete, work-in-progress API. It is not yet
11 * stable and is definitely going to change.
12 */
13
14/**
15 * @mainpage libghostty-vt - Virtual Terminal Emulator Library
16 *
17 * libghostty-vt is a C library which implements a modern terminal emulator,
18 * extracted from the [Ghostty](https://ghostty.org) terminal emulator.
19 *
20 * libghostty-vt contains the logic for handling the core parts of a terminal
21 * emulator: parsing terminal escape sequences, maintaining terminal state,
22 * encoding input events, etc. It can handle scrollback, line wrapping,
23 * reflow on resize, and more.
24 *
25 * @warning This library is currently in development and the API is not yet stable.
26 * Breaking changes are expected in future versions. Use with caution in production code.
27 *
28 * @section groups_sec API Reference
29 *
30 * The API is organized into the following groups:
31 * - @ref key "Key Encoding" - Encode key events into terminal sequences
32 * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
33 * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
34 * - @ref paste "Paste Utilities" - Validate paste data safety
35 * - @ref allocator "Memory Management" - Memory management and custom allocators
36 * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions
37 *
38 * @section examples_sec Examples
39 *
40 * Complete working examples:
41 * - @ref c-vt/src/main.c - OSC parser example
42 * - @ref c-vt-key-encode/src/main.c - Key encoding example
43 * - @ref c-vt-paste/src/main.c - Paste safety check example
44 * - @ref c-vt-sgr/src/main.c - SGR parser example
45 *
46 */
47
48/** @example c-vt/src/main.c
49 * This example demonstrates how to use the OSC parser to parse an OSC sequence,
50 * extract command information, and retrieve command-specific data like window titles.
51 */
52
53/** @example c-vt-key-encode/src/main.c
54 * This example demonstrates how to use the key encoder to convert key events
55 * into terminal escape sequences using the Kitty keyboard protocol.
56 */
57
58/** @example c-vt-paste/src/main.c
59 * This example demonstrates how to use the paste utilities to check if
60 * paste data is safe before sending it to the terminal.
61 */
62
63/** @example c-vt-sgr/src/main.c
64 * This example demonstrates how to use the SGR parser to parse terminal
65 * styling sequences and extract text attributes like colors and underline styles.
66 */
67
68#ifndef GHOSTTY_VT_H
69#define GHOSTTY_VT_H
70
71#ifdef __cplusplus
72extern "C" {
73#endif
74
75#include <ghostty/vt/result.h>
76#include <ghostty/vt/allocator.h>
77#include <ghostty/vt/osc.h>
78#include <ghostty/vt/sgr.h>
79#include <ghostty/vt/key.h>
80#include <ghostty/vt/paste.h>
81#include <ghostty/vt/wasm.h>
82
83#ifdef __cplusplus
84}
85#endif
86
87#endif /* GHOSTTY_VT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h created+196
...@@ -0,0 +1,196 @@
1/**
2 * @file allocator.h
3 *
4 * Memory management interface for libghostty-vt.
5 */
6
7#ifndef GHOSTTY_VT_ALLOCATOR_H
8#define GHOSTTY_VT_ALLOCATOR_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13
14/** @defgroup allocator Memory Management
15 *
16 * libghostty-vt does require memory allocation for various operations,
17 * but is resilient to allocation failures and will gracefully handle
18 * out-of-memory situations by returning error codes.
19 *
20 * The exact memory management semantics are documented in the relevant
21 * functions and data structures.
22 *
23 * libghostty-vt uses explicit memory allocation via an allocator
24 * interface provided by GhosttyAllocator. The interface is based on the
25 * [Zig](https://ziglang.org) allocator interface, since this has been
26 * shown to be a flexible and powerful interface in practice and enables
27 * a wide variety of allocation strategies.
28 *
29 * **For the common case, you can pass NULL as the allocator for any
30 * function that accepts one,** and libghostty will use a default allocator.
31 * The default allocator will be libc malloc/free if libc is linked.
32 * Otherwise, a custom allocator is used (currently Zig's SMP allocator)
33 * that doesn't require any external dependencies.
34 *
35 * ## Basic Usage
36 *
37 * For simple use cases, you can ignore this interface entirely by passing NULL
38 * as the allocator parameter to functions that accept one. This will use the
39 * default allocator (typically libc malloc/free, if libc is linked, but
40 * we provide our own default allocator if libc isn't linked).
41 *
42 * To use a custom allocator:
43 * 1. Implement the GhosttyAllocatorVtable function pointers
44 * 2. Create a GhosttyAllocator struct with your vtable and context
45 * 3. Pass the allocator to functions that accept one
46 *
47 * @{
48 */
49
50/**
51 * Function table for custom memory allocator operations.
52 *
53 * This vtable defines the interface for a custom memory allocator. All
54 * function pointers must be valid and non-NULL.
55 *
56 * @ingroup allocator
57 *
58 * If you're not going to use a custom allocator, you can ignore all of
59 * this. All functions that take an allocator pointer allow NULL to use a
60 * default allocator.
61 *
62 * The interface is based on the Zig allocator interface. I'll say up front
63 * that it is easy to look at this interface and think "wow, this is really
64 * overcomplicated". The reason for this complexity is well thought out by
65 * the Zig folks, and it enables a diverse set of allocation strategies
66 * as shown by the Zig ecosystem. As a consolation, please note that many
67 * of the arguments are only needed for advanced use cases and can be
68 * safely ignored in simple implementations. For example, if you look at
69 * the Zig implementation of the libc allocator in `lib/std/heap.zig`
70 * (search for CAllocator), you'll see it is very simple.
71 *
72 * We chose to align with the Zig allocator interface because:
73 *
74 * 1. It is a proven interface that serves a wide variety of use cases
75 * in the real world via the Zig ecosystem. It's shown to work.
76 *
77 * 2. Our core implementation itself is Zig, and this lets us very
78 * cheaply and easily convert between C and Zig allocators.
79 *
80 * NOTE(mitchellh): In the future, we can have default implementations of
81 * resize/remap and allow those to be null.
82 */
83typedef struct {
84 /**
85 * Return a pointer to `len` bytes with specified `alignment`, or return
86 * `NULL` indicating the allocation failed.
87 *
88 * @param ctx The allocator context
89 * @param len Number of bytes to allocate
90 * @param alignment Required alignment for the allocation. Guaranteed to
91 * be a power of two between 1 and 16 inclusive.
92 * @param ret_addr First return address of the allocation call stack (0 if not provided)
93 * @return Pointer to allocated memory, or NULL if allocation failed
94 */
95 void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr);
96
97 /**
98 * Attempt to expand or shrink memory in place.
99 *
100 * `memory_len` must equal the length requested from the most recent
101 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
102 * equal the same value that was passed as the `alignment` parameter to
103 * the original `alloc` call.
104 *
105 * `new_len` must be greater than zero.
106 *
107 * @param ctx The allocator context
108 * @param memory Pointer to the memory block to resize
109 * @param memory_len Current size of the memory block
110 * @param alignment Alignment (must match original allocation)
111 * @param new_len New requested size
112 * @param ret_addr First return address of the allocation call stack (0 if not provided)
113 * @return true if resize was successful in-place, false if relocation would be required
114 */
115 bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
116
117 /**
118 * Attempt to expand or shrink memory, allowing relocation.
119 *
120 * `memory_len` must equal the length requested from the most recent
121 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
122 * equal the same value that was passed as the `alignment` parameter to
123 * the original `alloc` call.
124 *
125 * A non-`NULL` return value indicates the resize was successful. The
126 * allocation may have same address, or may have been relocated. In either
127 * case, the allocation now has size of `new_len`. A `NULL` return value
128 * indicates that the resize would be equivalent to allocating new memory,
129 * copying the bytes from the old memory, and then freeing the old memory.
130 * In such case, it is more efficient for the caller to perform the copy.
131 *
132 * `new_len` must be greater than zero.
133 *
134 * @param ctx The allocator context
135 * @param memory Pointer to the memory block to remap
136 * @param memory_len Current size of the memory block
137 * @param alignment Alignment (must match original allocation)
138 * @param new_len New requested size
139 * @param ret_addr First return address of the allocation call stack (0 if not provided)
140 * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed
141 */
142 void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
143
144 /**
145 * Free and invalidate a region of memory.
146 *
147 * `memory_len` must equal the length requested from the most recent
148 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
149 * equal the same value that was passed as the `alignment` parameter to
150 * the original `alloc` call.
151 *
152 * @param ctx The allocator context
153 * @param memory Pointer to the memory block to free
154 * @param memory_len Size of the memory block
155 * @param alignment Alignment (must match original allocation)
156 * @param ret_addr First return address of the allocation call stack (0 if not provided)
157 */
158 void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr);
159} GhosttyAllocatorVtable;
160
161/**
162 * Custom memory allocator.
163 *
164 * For functions that take an allocator pointer, a NULL pointer indicates
165 * that the default allocator should be used. The default allocator will
166 * be libc malloc/free if we're linking to libc. If libc isn't linked,
167 * a custom allocator is used (currently Zig's SMP allocator).
168 *
169 * @ingroup allocator
170 *
171 * Usage example:
172 * @code
173 * GhosttyAllocator allocator = {
174 * .vtable = &my_allocator_vtable,
175 * .ctx = my_allocator_state
176 * };
177 * @endcode
178 */
179typedef struct GhosttyAllocator {
180 /**
181 * Opaque context pointer passed to all vtable functions.
182 * This allows the allocator implementation to maintain state
183 * or reference external resources needed for memory management.
184 */
185 void *ctx;
186
187 /**
188 * Pointer to the allocator's vtable containing function pointers
189 * for memory operations (alloc, resize, remap, free).
190 */
191 const GhosttyAllocatorVtable *vtable;
192} GhosttyAllocator;
193
194/** @} */
195
196#endif /* GHOSTTY_VT_ALLOCATOR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h created+96
...@@ -0,0 +1,96 @@
1/**
2 * @file color.h
3 *
4 * Color types and utilities.
5 */
6
7#ifndef GHOSTTY_VT_COLOR_H
8#define GHOSTTY_VT_COLOR_H
9
10#include <stdint.h>
11
12#ifdef __cplusplus
13extern "C" {
14#endif
15
16/**
17 * RGB color value.
18 *
19 * @ingroup sgr
20 */
21typedef struct {
22 uint8_t r; /**< Red component (0-255) */
23 uint8_t g; /**< Green component (0-255) */
24 uint8_t b; /**< Blue component (0-255) */
25} GhosttyColorRgb;
26
27/**
28 * Palette color index (0-255).
29 *
30 * @ingroup sgr
31 */
32typedef uint8_t GhosttyColorPaletteIndex;
33
34/** @addtogroup sgr
35 * @{
36 */
37
38/** Black color (0) @ingroup sgr */
39#define GHOSTTY_COLOR_NAMED_BLACK 0
40/** Red color (1) @ingroup sgr */
41#define GHOSTTY_COLOR_NAMED_RED 1
42/** Green color (2) @ingroup sgr */
43#define GHOSTTY_COLOR_NAMED_GREEN 2
44/** Yellow color (3) @ingroup sgr */
45#define GHOSTTY_COLOR_NAMED_YELLOW 3
46/** Blue color (4) @ingroup sgr */
47#define GHOSTTY_COLOR_NAMED_BLUE 4
48/** Magenta color (5) @ingroup sgr */
49#define GHOSTTY_COLOR_NAMED_MAGENTA 5
50/** Cyan color (6) @ingroup sgr */
51#define GHOSTTY_COLOR_NAMED_CYAN 6
52/** White color (7) @ingroup sgr */
53#define GHOSTTY_COLOR_NAMED_WHITE 7
54/** Bright black color (8) @ingroup sgr */
55#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8
56/** Bright red color (9) @ingroup sgr */
57#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9
58/** Bright green color (10) @ingroup sgr */
59#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10
60/** Bright yellow color (11) @ingroup sgr */
61#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11
62/** Bright blue color (12) @ingroup sgr */
63#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12
64/** Bright magenta color (13) @ingroup sgr */
65#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13
66/** Bright cyan color (14) @ingroup sgr */
67#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14
68/** Bright white color (15) @ingroup sgr */
69#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15
70
71/** @} */
72
73/**
74 * Get the RGB color components.
75 *
76 * This function extracts the individual red, green, and blue components
77 * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments
78 * where accessing struct fields directly is difficult.
79 *
80 * @param color The RGB color value
81 * @param r Pointer to store the red component (0-255)
82 * @param g Pointer to store the green component (0-255)
83 * @param b Pointer to store the blue component (0-255)
84 *
85 * @ingroup sgr
86 */
87void ghostty_color_rgb_get(GhosttyColorRgb color,
88 uint8_t* r,
89 uint8_t* g,
90 uint8_t* b);
91
92#ifdef __cplusplus
93}
94#endif
95
96#endif /* GHOSTTY_VT_COLOR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h created+80
...@@ -0,0 +1,80 @@
1/**
2 * @file key.h
3 *
4 * Key encoding module - encode key events into terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_KEY_H
8#define GHOSTTY_VT_KEY_H
9
10/** @defgroup key Key Encoding
11 *
12 * Utilities for encoding key events into terminal escape sequences,
13 * supporting both legacy encoding as well as Kitty Keyboard Protocol.
14 *
15 * ## Basic Usage
16 *
17 * 1. Create an encoder instance with ghostty_key_encoder_new()
18 * 2. Configure encoder options with ghostty_key_encoder_setopt().
19 * 3. For each key event:
20 * - Create a key event with ghostty_key_event_new()
21 * - Set event properties (action, key, modifiers, etc.)
22 * - Encode with ghostty_key_encoder_encode()
23 * - Free the event with ghostty_key_event_free()
24 * - Note: You can also reuse the same key event multiple times by
25 * changing its properties.
26 * 4. Free the encoder with ghostty_key_encoder_free() when done
27 *
28 * ## Example
29 *
30 * @code{.c}
31 * #include <assert.h>
32 * #include <stdio.h>
33 * #include <ghostty/vt.h>
34 *
35 * int main() {
36 * // Create encoder
37 * GhosttyKeyEncoder encoder;
38 * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder);
39 * assert(result == GHOSTTY_SUCCESS);
40 *
41 * // Enable Kitty keyboard protocol with all features
42 * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS,
43 * &(uint8_t){GHOSTTY_KITTY_KEY_ALL});
44 *
45 * // Create and configure key event for Ctrl+C press
46 * GhosttyKeyEvent event;
47 * result = ghostty_key_event_new(NULL, &event);
48 * assert(result == GHOSTTY_SUCCESS);
49 * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS);
50 * ghostty_key_event_set_key(event, GHOSTTY_KEY_C);
51 * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL);
52 *
53 * // Encode the key event
54 * char buf[128];
55 * size_t written = 0;
56 * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
57 * assert(result == GHOSTTY_SUCCESS);
58 *
59 * // Use the encoded sequence (e.g., write to terminal)
60 * fwrite(buf, 1, written, stdout);
61 *
62 * // Cleanup
63 * ghostty_key_event_free(event);
64 * ghostty_key_encoder_free(encoder);
65 * return 0;
66 * }
67 * @endcode
68 *
69 * For a complete working example, see example/c-vt-key-encode in the
70 * repository.
71 *
72 * @{
73 */
74
75#include <ghostty/vt/key/event.h>
76#include <ghostty/vt/key/encoder.h>
77
78/** @} */
79
80#endif /* GHOSTTY_VT_KEY_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h created+221
...@@ -0,0 +1,221 @@
1/**
2 * @file encoder.h
3 *
4 * Key event encoding to terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_KEY_ENCODER_H
8#define GHOSTTY_VT_KEY_ENCODER_H
9
10#include <stddef.h>
11#include <stdint.h>
12#include <ghostty/vt/result.h>
13#include <ghostty/vt/allocator.h>
14#include <ghostty/vt/key/event.h>
15
16/**
17 * Opaque handle to a key encoder instance.
18 *
19 * This handle represents a key encoder that converts key events into terminal
20 * escape sequences.
21 *
22 * @ingroup key
23 */
24typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder;
25
26/**
27 * Kitty keyboard protocol flags.
28 *
29 * Bitflags representing the various modes of the Kitty keyboard protocol.
30 * These can be combined using bitwise OR operations. Valid values all
31 * start with `GHOSTTY_KITTY_KEY_`.
32 *
33 * @ingroup key
34 */
35typedef uint8_t GhosttyKittyKeyFlags;
36
37/** Kitty keyboard protocol disabled (all flags off) */
38#define GHOSTTY_KITTY_KEY_DISABLED 0
39
40/** Disambiguate escape codes */
41#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0)
42
43/** Report key press and release events */
44#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1)
45
46/** Report alternate key codes */
47#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2)
48
49/** Report all key events including those normally handled by the terminal */
50#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3)
51
52/** Report associated text with key events */
53#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4)
54
55/** All Kitty keyboard protocol flags enabled */
56#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED)
57
58/**
59 * macOS option key behavior.
60 *
61 * Determines whether the "option" key on macOS is treated as "alt" or not.
62 * See the Ghostty `macos-option-as-alt` configuration option for more details.
63 *
64 * @ingroup key
65 */
66typedef enum {
67 /** Option key is not treated as alt */
68 GHOSTTY_OPTION_AS_ALT_FALSE = 0,
69 /** Option key is treated as alt */
70 GHOSTTY_OPTION_AS_ALT_TRUE = 1,
71 /** Only left option key is treated as alt */
72 GHOSTTY_OPTION_AS_ALT_LEFT = 2,
73 /** Only right option key is treated as alt */
74 GHOSTTY_OPTION_AS_ALT_RIGHT = 3,
75} GhosttyOptionAsAlt;
76
77/**
78 * Key encoder option identifiers.
79 *
80 * These values are used with ghostty_key_encoder_setopt() to configure
81 * the behavior of the key encoder.
82 *
83 * @ingroup key
84 */
85typedef enum {
86 /** Terminal DEC mode 1: cursor key application mode (value: bool) */
87 GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0,
88
89 /** Terminal DEC mode 66: keypad key application mode (value: bool) */
90 GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1,
91
92 /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */
93 GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2,
94
95 /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */
96 GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3,
97
98 /** xterm modifyOtherKeys mode 2 (value: bool) */
99 GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4,
100
101 /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */
102 GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5,
103
104 /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */
105 GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6,
106} GhosttyKeyEncoderOption;
107
108/**
109 * Create a new key encoder instance.
110 *
111 * Creates a new key encoder with default options. The encoder can be configured
112 * using ghostty_key_encoder_setopt() and must be freed using
113 * ghostty_key_encoder_free() when no longer needed.
114 *
115 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
116 * @param encoder Pointer to store the created encoder handle
117 * @return GHOSTTY_SUCCESS on success, or an error code on failure
118 *
119 * @ingroup key
120 */
121GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder);
122
123/**
124 * Free a key encoder instance.
125 *
126 * Releases all resources associated with the key encoder. After this call,
127 * the encoder handle becomes invalid and must not be used.
128 *
129 * @param encoder The encoder handle to free (may be NULL)
130 *
131 * @ingroup key
132 */
133void ghostty_key_encoder_free(GhosttyKeyEncoder encoder);
134
135/**
136 * Set an option on the key encoder.
137 *
138 * Configures the behavior of the key encoder. Options control various aspects
139 * of encoding such as terminal modes (cursor key application mode, keypad mode),
140 * protocol selection (Kitty keyboard protocol flags), and platform-specific
141 * behaviors (macOS option-as-alt).
142 *
143 * A null pointer value does nothing. It does not reset the value to the
144 * default. The setopt call will do nothing.
145 *
146 * @param encoder The encoder handle, must not be NULL
147 * @param option The option to set
148 * @param value Pointer to the value to set (type depends on the option)
149 *
150 * @ingroup key
151 */
152void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value);
153
154/**
155 * Encode a key event into a terminal escape sequence.
156 *
157 * Converts a key event into the appropriate terminal escape sequence based on
158 * the encoder's current options. The sequence is written to the provided buffer.
159 *
160 * Not all key events produce output. For example, unmodified modifier keys
161 * typically don't generate escape sequences. Check the out_len parameter to
162 * determine if any data was written.
163 *
164 * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY
165 * and out_len will contain the required buffer size. The caller can then
166 * allocate a larger buffer and call the function again.
167 *
168 * @param encoder The encoder handle, must not be NULL
169 * @param event The key event to encode, must not be NULL
170 * @param out_buf Buffer to write the encoded sequence to
171 * @param out_buf_size Size of the output buffer in bytes
172 * @param out_len Pointer to store the number of bytes written (may be NULL)
173 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code
174 *
175 * ## Example: Calculate required buffer size
176 *
177 * @code{.c}
178 * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY)
179 * size_t required = 0;
180 * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required);
181 * assert(result == GHOSTTY_OUT_OF_MEMORY);
182 *
183 * // Allocate buffer of required size
184 * char *buf = malloc(required);
185 *
186 * // Encode with properly sized buffer
187 * size_t written = 0;
188 * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written);
189 * assert(result == GHOSTTY_SUCCESS);
190 *
191 * // Use the encoded sequence...
192 *
193 * free(buf);
194 * @endcode
195 *
196 * ## Example: Direct encoding with static buffer
197 *
198 * @code{.c}
199 * // Most escape sequences are short, so a static buffer often suffices
200 * char buf[128];
201 * size_t written = 0;
202 * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
203 *
204 * if (result == GHOSTTY_SUCCESS) {
205 * // Write the encoded sequence to the terminal
206 * write(pty_fd, buf, written);
207 * } else if (result == GHOSTTY_OUT_OF_MEMORY) {
208 * // Buffer too small, written contains required size
209 * char *dynamic_buf = malloc(written);
210 * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written);
211 * assert(result == GHOSTTY_SUCCESS);
212 * write(pty_fd, dynamic_buf, written);
213 * free(dynamic_buf);
214 * }
215 * @endcode
216 *
217 * @ingroup key
218 */
219GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len);
220
221#endif /* GHOSTTY_VT_KEY_ENCODER_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h created+474
...@@ -0,0 +1,474 @@
1/**
2 * @file event.h
3 *
4 * Key event representation and manipulation.
5 */
6
7#ifndef GHOSTTY_VT_KEY_EVENT_H
8#define GHOSTTY_VT_KEY_EVENT_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/result.h>
14#include <ghostty/vt/allocator.h>
15
16/**
17 * Opaque handle to a key event.
18 *
19 * This handle represents a keyboard input event containing information about
20 * the physical key pressed, modifiers, and generated text.
21 *
22 * @ingroup key
23 */
24typedef struct GhosttyKeyEvent *GhosttyKeyEvent;
25
26/**
27 * Keyboard input event types.
28 *
29 * @ingroup key
30 */
31typedef enum {
32 /** Key was released */
33 GHOSTTY_KEY_ACTION_RELEASE = 0,
34 /** Key was pressed */
35 GHOSTTY_KEY_ACTION_PRESS = 1,
36 /** Key is being repeated (held down) */
37 GHOSTTY_KEY_ACTION_REPEAT = 2,
38} GhosttyKeyAction;
39
40/**
41 * Keyboard modifier keys bitmask.
42 *
43 * A bitmask representing all keyboard modifiers. This tracks which modifier keys
44 * are pressed and, where supported by the platform, which side (left or right)
45 * of each modifier is active.
46 *
47 * Use the GHOSTTY_MODS_* constants to test and set individual modifiers.
48 *
49 * Modifier side bits are only meaningful when the corresponding modifier bit is set.
50 * Not all platforms support distinguishing between left and right modifier
51 * keys and Ghostty is built to expect that some platforms may not provide this
52 * information.
53 *
54 * @ingroup key
55 */
56typedef uint16_t GhosttyMods;
57
58/** Shift key is pressed */
59#define GHOSTTY_MODS_SHIFT (1 << 0)
60/** Control key is pressed */
61#define GHOSTTY_MODS_CTRL (1 << 1)
62/** Alt/Option key is pressed */
63#define GHOSTTY_MODS_ALT (1 << 2)
64/** Super/Command/Windows key is pressed */
65#define GHOSTTY_MODS_SUPER (1 << 3)
66/** Caps Lock is active */
67#define GHOSTTY_MODS_CAPS_LOCK (1 << 4)
68/** Num Lock is active */
69#define GHOSTTY_MODS_NUM_LOCK (1 << 5)
70
71/**
72 * Right shift is pressed (0 = left, 1 = right).
73 * Only meaningful when GHOSTTY_MODS_SHIFT is set.
74 */
75#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6)
76/**
77 * Right ctrl is pressed (0 = left, 1 = right).
78 * Only meaningful when GHOSTTY_MODS_CTRL is set.
79 */
80#define GHOSTTY_MODS_CTRL_SIDE (1 << 7)
81/**
82 * Right alt is pressed (0 = left, 1 = right).
83 * Only meaningful when GHOSTTY_MODS_ALT is set.
84 */
85#define GHOSTTY_MODS_ALT_SIDE (1 << 8)
86/**
87 * Right super is pressed (0 = left, 1 = right).
88 * Only meaningful when GHOSTTY_MODS_SUPER is set.
89 */
90#define GHOSTTY_MODS_SUPER_SIDE (1 << 9)
91
92/**
93 * Physical key codes.
94 *
95 * The set of key codes that Ghostty is aware of. These represent physical keys
96 * on the keyboard and are layout-independent. For example, the "a" key on a US
97 * keyboard is the same as the "ф" key on a Russian keyboard, but both will
98 * report the same key_a value.
99 *
100 * Layout-dependent strings are provided separately as UTF-8 text and are produced
101 * by the platform. These values are based on the W3C UI Events KeyboardEvent code
102 * standard. See: https://www.w3.org/TR/uievents-code
103 *
104 * @ingroup key
105 */
106typedef enum {
107 GHOSTTY_KEY_UNIDENTIFIED = 0,
108
109 // Writing System Keys (W3C § 3.1.1)
110 GHOSTTY_KEY_BACKQUOTE,
111 GHOSTTY_KEY_BACKSLASH,
112 GHOSTTY_KEY_BRACKET_LEFT,
113 GHOSTTY_KEY_BRACKET_RIGHT,
114 GHOSTTY_KEY_COMMA,
115 GHOSTTY_KEY_DIGIT_0,
116 GHOSTTY_KEY_DIGIT_1,
117 GHOSTTY_KEY_DIGIT_2,
118 GHOSTTY_KEY_DIGIT_3,
119 GHOSTTY_KEY_DIGIT_4,
120 GHOSTTY_KEY_DIGIT_5,
121 GHOSTTY_KEY_DIGIT_6,
122 GHOSTTY_KEY_DIGIT_7,
123 GHOSTTY_KEY_DIGIT_8,
124 GHOSTTY_KEY_DIGIT_9,
125 GHOSTTY_KEY_EQUAL,
126 GHOSTTY_KEY_INTL_BACKSLASH,
127 GHOSTTY_KEY_INTL_RO,
128 GHOSTTY_KEY_INTL_YEN,
129 GHOSTTY_KEY_A,
130 GHOSTTY_KEY_B,
131 GHOSTTY_KEY_C,
132 GHOSTTY_KEY_D,
133 GHOSTTY_KEY_E,
134 GHOSTTY_KEY_F,
135 GHOSTTY_KEY_G,
136 GHOSTTY_KEY_H,
137 GHOSTTY_KEY_I,
138 GHOSTTY_KEY_J,
139 GHOSTTY_KEY_K,
140 GHOSTTY_KEY_L,
141 GHOSTTY_KEY_M,
142 GHOSTTY_KEY_N,
143 GHOSTTY_KEY_O,
144 GHOSTTY_KEY_P,
145 GHOSTTY_KEY_Q,
146 GHOSTTY_KEY_R,
147 GHOSTTY_KEY_S,
148 GHOSTTY_KEY_T,
149 GHOSTTY_KEY_U,
150 GHOSTTY_KEY_V,
151 GHOSTTY_KEY_W,
152 GHOSTTY_KEY_X,
153 GHOSTTY_KEY_Y,
154 GHOSTTY_KEY_Z,
155 GHOSTTY_KEY_MINUS,
156 GHOSTTY_KEY_PERIOD,
157 GHOSTTY_KEY_QUOTE,
158 GHOSTTY_KEY_SEMICOLON,
159 GHOSTTY_KEY_SLASH,
160
161 // Functional Keys (W3C § 3.1.2)
162 GHOSTTY_KEY_ALT_LEFT,
163 GHOSTTY_KEY_ALT_RIGHT,
164 GHOSTTY_KEY_BACKSPACE,
165 GHOSTTY_KEY_CAPS_LOCK,
166 GHOSTTY_KEY_CONTEXT_MENU,
167 GHOSTTY_KEY_CONTROL_LEFT,
168 GHOSTTY_KEY_CONTROL_RIGHT,
169 GHOSTTY_KEY_ENTER,
170 GHOSTTY_KEY_META_LEFT,
171 GHOSTTY_KEY_META_RIGHT,
172 GHOSTTY_KEY_SHIFT_LEFT,
173 GHOSTTY_KEY_SHIFT_RIGHT,
174 GHOSTTY_KEY_SPACE,
175 GHOSTTY_KEY_TAB,
176 GHOSTTY_KEY_CONVERT,
177 GHOSTTY_KEY_KANA_MODE,
178 GHOSTTY_KEY_NON_CONVERT,
179
180 // Control Pad Section (W3C § 3.2)
181 GHOSTTY_KEY_DELETE,
182 GHOSTTY_KEY_END,
183 GHOSTTY_KEY_HELP,
184 GHOSTTY_KEY_HOME,
185 GHOSTTY_KEY_INSERT,
186 GHOSTTY_KEY_PAGE_DOWN,
187 GHOSTTY_KEY_PAGE_UP,
188
189 // Arrow Pad Section (W3C § 3.3)
190 GHOSTTY_KEY_ARROW_DOWN,
191 GHOSTTY_KEY_ARROW_LEFT,
192 GHOSTTY_KEY_ARROW_RIGHT,
193 GHOSTTY_KEY_ARROW_UP,
194
195 // Numpad Section (W3C § 3.4)
196 GHOSTTY_KEY_NUM_LOCK,
197 GHOSTTY_KEY_NUMPAD_0,
198 GHOSTTY_KEY_NUMPAD_1,
199 GHOSTTY_KEY_NUMPAD_2,
200 GHOSTTY_KEY_NUMPAD_3,
201 GHOSTTY_KEY_NUMPAD_4,
202 GHOSTTY_KEY_NUMPAD_5,
203 GHOSTTY_KEY_NUMPAD_6,
204 GHOSTTY_KEY_NUMPAD_7,
205 GHOSTTY_KEY_NUMPAD_8,
206 GHOSTTY_KEY_NUMPAD_9,
207 GHOSTTY_KEY_NUMPAD_ADD,
208 GHOSTTY_KEY_NUMPAD_BACKSPACE,
209 GHOSTTY_KEY_NUMPAD_CLEAR,
210 GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
211 GHOSTTY_KEY_NUMPAD_COMMA,
212 GHOSTTY_KEY_NUMPAD_DECIMAL,
213 GHOSTTY_KEY_NUMPAD_DIVIDE,
214 GHOSTTY_KEY_NUMPAD_ENTER,
215 GHOSTTY_KEY_NUMPAD_EQUAL,
216 GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
217 GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
218 GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
219 GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
220 GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
221 GHOSTTY_KEY_NUMPAD_MULTIPLY,
222 GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
223 GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
224 GHOSTTY_KEY_NUMPAD_SUBTRACT,
225 GHOSTTY_KEY_NUMPAD_SEPARATOR,
226 GHOSTTY_KEY_NUMPAD_UP,
227 GHOSTTY_KEY_NUMPAD_DOWN,
228 GHOSTTY_KEY_NUMPAD_RIGHT,
229 GHOSTTY_KEY_NUMPAD_LEFT,
230 GHOSTTY_KEY_NUMPAD_BEGIN,
231 GHOSTTY_KEY_NUMPAD_HOME,
232 GHOSTTY_KEY_NUMPAD_END,
233 GHOSTTY_KEY_NUMPAD_INSERT,
234 GHOSTTY_KEY_NUMPAD_DELETE,
235 GHOSTTY_KEY_NUMPAD_PAGE_UP,
236 GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
237
238 // Function Section (W3C § 3.5)
239 GHOSTTY_KEY_ESCAPE,
240 GHOSTTY_KEY_F1,
241 GHOSTTY_KEY_F2,
242 GHOSTTY_KEY_F3,
243 GHOSTTY_KEY_F4,
244 GHOSTTY_KEY_F5,
245 GHOSTTY_KEY_F6,
246 GHOSTTY_KEY_F7,
247 GHOSTTY_KEY_F8,
248 GHOSTTY_KEY_F9,
249 GHOSTTY_KEY_F10,
250 GHOSTTY_KEY_F11,
251 GHOSTTY_KEY_F12,
252 GHOSTTY_KEY_F13,
253 GHOSTTY_KEY_F14,
254 GHOSTTY_KEY_F15,
255 GHOSTTY_KEY_F16,
256 GHOSTTY_KEY_F17,
257 GHOSTTY_KEY_F18,
258 GHOSTTY_KEY_F19,
259 GHOSTTY_KEY_F20,
260 GHOSTTY_KEY_F21,
261 GHOSTTY_KEY_F22,
262 GHOSTTY_KEY_F23,
263 GHOSTTY_KEY_F24,
264 GHOSTTY_KEY_F25,
265 GHOSTTY_KEY_FN,
266 GHOSTTY_KEY_FN_LOCK,
267 GHOSTTY_KEY_PRINT_SCREEN,
268 GHOSTTY_KEY_SCROLL_LOCK,
269 GHOSTTY_KEY_PAUSE,
270
271 // Media Keys (W3C § 3.6)
272 GHOSTTY_KEY_BROWSER_BACK,
273 GHOSTTY_KEY_BROWSER_FAVORITES,
274 GHOSTTY_KEY_BROWSER_FORWARD,
275 GHOSTTY_KEY_BROWSER_HOME,
276 GHOSTTY_KEY_BROWSER_REFRESH,
277 GHOSTTY_KEY_BROWSER_SEARCH,
278 GHOSTTY_KEY_BROWSER_STOP,
279 GHOSTTY_KEY_EJECT,
280 GHOSTTY_KEY_LAUNCH_APP_1,
281 GHOSTTY_KEY_LAUNCH_APP_2,
282 GHOSTTY_KEY_LAUNCH_MAIL,
283 GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
284 GHOSTTY_KEY_MEDIA_SELECT,
285 GHOSTTY_KEY_MEDIA_STOP,
286 GHOSTTY_KEY_MEDIA_TRACK_NEXT,
287 GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
288 GHOSTTY_KEY_POWER,
289 GHOSTTY_KEY_SLEEP,
290 GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
291 GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
292 GHOSTTY_KEY_AUDIO_VOLUME_UP,
293 GHOSTTY_KEY_WAKE_UP,
294
295 // Legacy, Non-standard, and Special Keys (W3C § 3.7)
296 GHOSTTY_KEY_COPY,
297 GHOSTTY_KEY_CUT,
298 GHOSTTY_KEY_PASTE,
299} GhosttyKey;
300
301/**
302 * Create a new key event instance.
303 *
304 * Creates a new key event with default values. The event must be freed using
305 * ghostty_key_event_free() when no longer needed.
306 *
307 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
308 * @param event Pointer to store the created key event handle
309 * @return GHOSTTY_SUCCESS on success, or an error code on failure
310 *
311 * @ingroup key
312 */
313GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event);
314
315/**
316 * Free a key event instance.
317 *
318 * Releases all resources associated with the key event. After this call,
319 * the event handle becomes invalid and must not be used.
320 *
321 * @param event The key event handle to free (may be NULL)
322 *
323 * @ingroup key
324 */
325void ghostty_key_event_free(GhosttyKeyEvent event);
326
327/**
328 * Set the key action (press, release, repeat).
329 *
330 * @param event The key event handle, must not be NULL
331 * @param action The action to set
332 *
333 * @ingroup key
334 */
335void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action);
336
337/**
338 * Get the key action (press, release, repeat).
339 *
340 * @param event The key event handle, must not be NULL
341 * @return The key action
342 *
343 * @ingroup key
344 */
345GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event);
346
347/**
348 * Set the physical key code.
349 *
350 * @param event The key event handle, must not be NULL
351 * @param key The physical key code to set
352 *
353 * @ingroup key
354 */
355void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key);
356
357/**
358 * Get the physical key code.
359 *
360 * @param event The key event handle, must not be NULL
361 * @return The physical key code
362 *
363 * @ingroup key
364 */
365GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event);
366
367/**
368 * Set the modifier keys bitmask.
369 *
370 * @param event The key event handle, must not be NULL
371 * @param mods The modifier keys bitmask to set
372 *
373 * @ingroup key
374 */
375void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods);
376
377/**
378 * Get the modifier keys bitmask.
379 *
380 * @param event The key event handle, must not be NULL
381 * @return The modifier keys bitmask
382 *
383 * @ingroup key
384 */
385GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event);
386
387/**
388 * Set the consumed modifiers bitmask.
389 *
390 * @param event The key event handle, must not be NULL
391 * @param consumed_mods The consumed modifiers bitmask to set
392 *
393 * @ingroup key
394 */
395void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods);
396
397/**
398 * Get the consumed modifiers bitmask.
399 *
400 * @param event The key event handle, must not be NULL
401 * @return The consumed modifiers bitmask
402 *
403 * @ingroup key
404 */
405GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event);
406
407/**
408 * Set whether the key event is part of a composition sequence.
409 *
410 * @param event The key event handle, must not be NULL
411 * @param composing Whether the key event is part of a composition sequence
412 *
413 * @ingroup key
414 */
415void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing);
416
417/**
418 * Get whether the key event is part of a composition sequence.
419 *
420 * @param event The key event handle, must not be NULL
421 * @return Whether the key event is part of a composition sequence
422 *
423 * @ingroup key
424 */
425bool ghostty_key_event_get_composing(GhosttyKeyEvent event);
426
427/**
428 * Set the UTF-8 text generated by the key event.
429 *
430 * The key event does NOT take ownership of the text pointer. The caller
431 * must ensure the string remains valid for the lifetime needed by the event.
432 *
433 * @param event The key event handle, must not be NULL
434 * @param utf8 The UTF-8 text to set (or NULL for empty)
435 * @param len Length of the UTF-8 text in bytes
436 *
437 * @ingroup key
438 */
439void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len);
440
441/**
442 * Get the UTF-8 text generated by the key event.
443 *
444 * The returned pointer is valid until the event is freed or the UTF-8 text is modified.
445 *
446 * @param event The key event handle, must not be NULL
447 * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL)
448 * @return The UTF-8 text (or NULL for empty)
449 *
450 * @ingroup key
451 */
452const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len);
453
454/**
455 * Set the unshifted Unicode codepoint.
456 *
457 * @param event The key event handle, must not be NULL
458 * @param codepoint The unshifted Unicode codepoint to set
459 *
460 * @ingroup key
461 */
462void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint);
463
464/**
465 * Get the unshifted Unicode codepoint.
466 *
467 * @param event The key event handle, must not be NULL
468 * @return The unshifted Unicode codepoint
469 *
470 * @ingroup key
471 */
472uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event);
473
474#endif /* GHOSTTY_VT_KEY_EVENT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h created+233
...@@ -0,0 +1,233 @@
1/**
2 * @file osc.h
3 *
4 * OSC (Operating System Command) sequence parser and command handling.
5 */
6
7#ifndef GHOSTTY_VT_OSC_H
8#define GHOSTTY_VT_OSC_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/result.h>
14#include <ghostty/vt/allocator.h>
15
16/**
17 * Opaque handle to an OSC parser instance.
18 *
19 * This handle represents an OSC (Operating System Command) parser that can
20 * be used to parse the contents of OSC sequences.
21 *
22 * @ingroup osc
23 */
24typedef struct GhosttyOscParser *GhosttyOscParser;
25
26/**
27 * Opaque handle to a single OSC command.
28 *
29 * This handle represents a parsed OSC (Operating System Command) command.
30 * The command can be queried for its type and associated data.
31 *
32 * @ingroup osc
33 */
34typedef struct GhosttyOscCommand *GhosttyOscCommand;
35
36/** @defgroup osc OSC Parser
37 *
38 * OSC (Operating System Command) sequence parser and command handling.
39 *
40 * The parser operates in a streaming fashion, processing input byte-by-byte
41 * to handle OSC sequences that may arrive in fragments across multiple reads.
42 * This interface makes it easy to integrate into most environments and avoids
43 * over-allocating buffers.
44 *
45 * ## Basic Usage
46 *
47 * 1. Create a parser instance with ghostty_osc_new()
48 * 2. Feed bytes to the parser using ghostty_osc_next()
49 * 3. Finalize parsing with ghostty_osc_end() to get the command
50 * 4. Query command type and extract data using ghostty_osc_command_type()
51 * and ghostty_osc_command_data()
52 * 5. Free the parser with ghostty_osc_free() when done
53 *
54 * @{
55 */
56
57/**
58 * OSC command types.
59 *
60 * @ingroup osc
61 */
62typedef enum {
63 GHOSTTY_OSC_COMMAND_INVALID = 0,
64 GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1,
65 GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2,
66 GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3,
67 GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4,
68 GHOSTTY_OSC_COMMAND_REPORT_PWD = 5,
69 GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6,
70 GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7,
71 GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8,
72 GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9,
73 GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10,
74 GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11,
75 GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12,
76 GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13,
77 GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14,
78 GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15,
79 GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16,
80 GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17,
81 GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18,
82 GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19,
83 GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
84 GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
85 GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
86} GhosttyOscCommandType;
87
88/**
89 * OSC command data types.
90 *
91 * These values specify what type of data to extract from an OSC command
92 * using `ghostty_osc_command_data`.
93 *
94 * @ingroup osc
95 */
96typedef enum {
97 /** Invalid data type. Never results in any data extraction. */
98 GHOSTTY_OSC_DATA_INVALID = 0,
99
100 /**
101 * Window title string data.
102 *
103 * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE
104 *
105 * Output type: const char ** (pointer to null-terminated string)
106 *
107 * Lifetime: Valid until the next call to any ghostty_osc_* function with
108 * the same parser instance. Memory is owned by the parser.
109 */
110 GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1,
111} GhosttyOscCommandData;
112
113/**
114 * Create a new OSC parser instance.
115 *
116 * Creates a new OSC (Operating System Command) parser using the provided
117 * allocator. The parser must be freed using ghostty_vt_osc_free() when
118 * no longer needed.
119 *
120 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
121 * @param parser Pointer to store the created parser handle
122 * @return GHOSTTY_SUCCESS on success, or an error code on failure
123 *
124 * @ingroup osc
125 */
126GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser);
127
128/**
129 * Free an OSC parser instance.
130 *
131 * Releases all resources associated with the OSC parser. After this call,
132 * the parser handle becomes invalid and must not be used.
133 *
134 * @param parser The parser handle to free (may be NULL)
135 *
136 * @ingroup osc
137 */
138void ghostty_osc_free(GhosttyOscParser parser);
139
140/**
141 * Reset an OSC parser instance to its initial state.
142 *
143 * Resets the parser state, clearing any partially parsed OSC sequences
144 * and returning the parser to its initial state. This is useful for
145 * reusing a parser instance or recovering from parse errors.
146 *
147 * @param parser The parser handle to reset, must not be null.
148 *
149 * @ingroup osc
150 */
151void ghostty_osc_reset(GhosttyOscParser parser);
152
153/**
154 * Parse the next byte in an OSC sequence.
155 *
156 * Processes a single byte as part of an OSC sequence. The parser maintains
157 * internal state to track the progress through the sequence. Call this
158 * function for each byte in the sequence data.
159 *
160 * When finished pumping the parser with bytes, call ghostty_osc_end
161 * to get the final result.
162 *
163 * @param parser The parser handle, must not be null.
164 * @param byte The next byte to parse
165 *
166 * @ingroup osc
167 */
168void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte);
169
170/**
171 * Finalize OSC parsing and retrieve the parsed command.
172 *
173 * Call this function after feeding all bytes of an OSC sequence to the parser
174 * using ghostty_osc_next() with the exception of the terminating character
175 * (ESC or ST). This function finalizes the parsing process and returns the
176 * parsed OSC command.
177 *
178 * The return value is never NULL. Invalid commands will return a command
179 * with type GHOSTTY_OSC_COMMAND_INVALID.
180 *
181 * The terminator parameter specifies the byte that terminated the OSC sequence
182 * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is
183 * preserved in the parsed command so that responses can use the same terminator
184 * format for better compatibility with the calling program. For commands that
185 * do not require a response, this parameter is ignored and the resulting
186 * command will not retain the terminator information.
187 *
188 * The returned command handle is valid until the next call to any
189 * `ghostty_osc_*` function with the same parser instance with the exception
190 * of command introspection functions such as `ghostty_osc_command_type`.
191 *
192 * @param parser The parser handle, must not be null.
193 * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST)
194 * @return Handle to the parsed OSC command
195 *
196 * @ingroup osc
197 */
198GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator);
199
200/**
201 * Get the type of an OSC command.
202 *
203 * Returns the type identifier for the given OSC command. This can be used
204 * to determine what kind of command was parsed and what data might be
205 * available from it.
206 *
207 * @param command The OSC command handle to query (may be NULL)
208 * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL
209 *
210 * @ingroup osc
211 */
212GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command);
213
214/**
215 * Extract data from an OSC command.
216 *
217 * Extracts typed data from the given OSC command based on the specified
218 * data type. The output pointer must be of the appropriate type for the
219 * requested data kind. Valid command types, output types, and memory
220 * safety information are documented in the `GhosttyOscCommandData` enum.
221 *
222 * @param command The OSC command handle to query (may be NULL)
223 * @param data The type of data to extract
224 * @param out Pointer to store the extracted data (type depends on data parameter)
225 * @return true if data extraction was successful, false otherwise
226 *
227 * @ingroup osc
228 */
229bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out);
230
231/** @} */
232
233#endif /* GHOSTTY_VT_OSC_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h created+75
...@@ -0,0 +1,75 @@
1/**
2 * @file paste.h
3 *
4 * Paste utilities - validate and encode paste data for terminal input.
5 */
6
7#ifndef GHOSTTY_VT_PASTE_H
8#define GHOSTTY_VT_PASTE_H
9
10/** @defgroup paste Paste Utilities
11 *
12 * Utilities for validating paste data safety.
13 *
14 * ## Basic Usage
15 *
16 * Use ghostty_paste_is_safe() to check if paste data contains potentially
17 * dangerous sequences before sending it to the terminal.
18 *
19 * ## Example
20 *
21 * @code{.c}
22 * #include <stdio.h>
23 * #include <string.h>
24 * #include <ghostty/vt.h>
25 *
26 * int main() {
27 * const char* safe_data = "hello world";
28 * const char* unsafe_data = "rm -rf /\n";
29 *
30 * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) {
31 * printf("Safe to paste\n");
32 * }
33 *
34 * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) {
35 * printf("Unsafe! Contains newline\n");
36 * }
37 *
38 * return 0;
39 * }
40 * @endcode
41 *
42 * @{
43 */
44
45#include <stdbool.h>
46#include <stddef.h>
47
48#ifdef __cplusplus
49extern "C" {
50#endif
51
52/**
53 * Check if paste data is safe to paste into the terminal.
54 *
55 * Data is considered unsafe if it contains:
56 * - Newlines (`\n`) which can inject commands
57 * - The bracketed paste end sequence (`\x1b[201~`) which can be used
58 * to exit bracketed paste mode and inject commands
59 *
60 * This check is conservative and considers data unsafe regardless of
61 * current terminal state.
62 *
63 * @param data The paste data to check (must not be NULL)
64 * @param len The length of the data in bytes
65 * @return true if the data is safe to paste, false otherwise
66 */
67bool ghostty_paste_is_safe(const char* data, size_t len);
68
69#ifdef __cplusplus
70}
71#endif
72
73/** @} */
74
75#endif /* GHOSTTY_VT_PASTE_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h created+22
...@@ -0,0 +1,22 @@
1/**
2 * @file result.h
3 *
4 * Result codes for libghostty-vt operations.
5 */
6
7#ifndef GHOSTTY_VT_RESULT_H
8#define GHOSTTY_VT_RESULT_H
9
10/**
11 * Result codes for libghostty-vt operations.
12 */
13typedef enum {
14 /** Operation completed successfully */
15 GHOSTTY_SUCCESS = 0,
16 /** Operation failed due to failed allocation */
17 GHOSTTY_OUT_OF_MEMORY = -1,
18 /** Operation failed due to invalid value */
19 GHOSTTY_INVALID_VALUE = -2,
20} GhosttyResult;
21
22#endif /* GHOSTTY_VT_RESULT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h created+394
...@@ -0,0 +1,394 @@
1/**
2 * @file sgr.h
3 *
4 * SGR (Select Graphic Rendition) attribute parsing and handling.
5 */
6
7#ifndef GHOSTTY_VT_SGR_H
8#define GHOSTTY_VT_SGR_H
9
10/** @defgroup sgr SGR Parser
11 *
12 * SGR (Select Graphic Rendition) attribute parser.
13 *
14 * SGR sequences are the syntax used to set styling attributes such as
15 * bold, italic, underline, and colors for text in terminal emulators.
16 * For example, you may be familiar with sequences like `ESC[1;31m`. The
17 * `1;31` is the SGR attribute list.
18 *
19 * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`)
20 * and returns individual text attributes like bold, italic, colors, etc.
21 * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed,
22 * and handles various color formats including 8-color, 16-color, 256-color,
23 * X11 named colors, and RGB in multiple formats.
24 *
25 * ## Basic Usage
26 *
27 * 1. Create a parser instance with ghostty_sgr_new()
28 * 2. Set SGR parameters with ghostty_sgr_set_params()
29 * 3. Iterate through attributes using ghostty_sgr_next()
30 * 4. Free the parser with ghostty_sgr_free() when done
31 *
32 * ## Example
33 *
34 * @code{.c}
35 * #include <assert.h>
36 * #include <stdio.h>
37 * #include <ghostty/vt.h>
38 *
39 * int main() {
40 * // Create parser
41 * GhosttySgrParser parser;
42 * GhosttyResult result = ghostty_sgr_new(NULL, &parser);
43 * assert(result == GHOSTTY_SUCCESS);
44 *
45 * // Parse "bold, red foreground" sequence: ESC[1;31m
46 * uint16_t params[] = {1, 31};
47 * result = ghostty_sgr_set_params(parser, params, NULL, 2);
48 * assert(result == GHOSTTY_SUCCESS);
49 *
50 * // Iterate through attributes
51 * GhosttySgrAttribute attr;
52 * while (ghostty_sgr_next(parser, &attr)) {
53 * switch (attr.tag) {
54 * case GHOSTTY_SGR_ATTR_BOLD:
55 * printf("Bold enabled\n");
56 * break;
57 * case GHOSTTY_SGR_ATTR_FG_8:
58 * printf("Foreground color: %d\n", attr.value.fg_8);
59 * break;
60 * default:
61 * break;
62 * }
63 * }
64 *
65 * // Cleanup
66 * ghostty_sgr_free(parser);
67 * return 0;
68 * }
69 * @endcode
70 *
71 * @{
72 */
73
74#include <ghostty/vt/allocator.h>
75#include <ghostty/vt/color.h>
76#include <ghostty/vt/result.h>
77#include <stdbool.h>
78#include <stddef.h>
79#include <stdint.h>
80
81#ifdef __cplusplus
82extern "C" {
83#endif
84
85/**
86 * Opaque handle to an SGR parser instance.
87 *
88 * This handle represents an SGR (Select Graphic Rendition) parser that can
89 * be used to parse SGR sequences and extract individual text attributes.
90 *
91 * @ingroup sgr
92 */
93typedef struct GhosttySgrParser* GhosttySgrParser;
94
95/**
96 * SGR attribute tags.
97 *
98 * These values identify the type of an SGR attribute in a tagged union.
99 * Use the tag to determine which field in the attribute value union to access.
100 *
101 * @ingroup sgr
102 */
103typedef enum {
104 GHOSTTY_SGR_ATTR_UNSET = 0,
105 GHOSTTY_SGR_ATTR_UNKNOWN = 1,
106 GHOSTTY_SGR_ATTR_BOLD = 2,
107 GHOSTTY_SGR_ATTR_RESET_BOLD = 3,
108 GHOSTTY_SGR_ATTR_ITALIC = 4,
109 GHOSTTY_SGR_ATTR_RESET_ITALIC = 5,
110 GHOSTTY_SGR_ATTR_FAINT = 6,
111 GHOSTTY_SGR_ATTR_UNDERLINE = 7,
112 GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8,
113 GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9,
114 GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10,
115 GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11,
116 GHOSTTY_SGR_ATTR_OVERLINE = 12,
117 GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13,
118 GHOSTTY_SGR_ATTR_BLINK = 14,
119 GHOSTTY_SGR_ATTR_RESET_BLINK = 15,
120 GHOSTTY_SGR_ATTR_INVERSE = 16,
121 GHOSTTY_SGR_ATTR_RESET_INVERSE = 17,
122 GHOSTTY_SGR_ATTR_INVISIBLE = 18,
123 GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19,
124 GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20,
125 GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21,
126 GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22,
127 GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23,
128 GHOSTTY_SGR_ATTR_BG_8 = 24,
129 GHOSTTY_SGR_ATTR_FG_8 = 25,
130 GHOSTTY_SGR_ATTR_RESET_FG = 26,
131 GHOSTTY_SGR_ATTR_RESET_BG = 27,
132 GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28,
133 GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29,
134 GHOSTTY_SGR_ATTR_BG_256 = 30,
135 GHOSTTY_SGR_ATTR_FG_256 = 31,
136} GhosttySgrAttributeTag;
137
138/**
139 * Underline style types.
140 *
141 * @ingroup sgr
142 */
143typedef enum {
144 GHOSTTY_SGR_UNDERLINE_NONE = 0,
145 GHOSTTY_SGR_UNDERLINE_SINGLE = 1,
146 GHOSTTY_SGR_UNDERLINE_DOUBLE = 2,
147 GHOSTTY_SGR_UNDERLINE_CURLY = 3,
148 GHOSTTY_SGR_UNDERLINE_DOTTED = 4,
149 GHOSTTY_SGR_UNDERLINE_DASHED = 5,
150} GhosttySgrUnderline;
151
152/**
153 * Unknown SGR attribute data.
154 *
155 * Contains the full parameter list and the partial list where parsing
156 * encountered an unknown or invalid sequence.
157 *
158 * @ingroup sgr
159 */
160typedef struct {
161 const uint16_t* full_ptr;
162 size_t full_len;
163 const uint16_t* partial_ptr;
164 size_t partial_len;
165} GhosttySgrUnknown;
166
167/**
168 * SGR attribute value union.
169 *
170 * This union contains all possible attribute values. Use the tag field
171 * to determine which union member is active. Attributes without associated
172 * data (like bold, italic) don't use the union value.
173 *
174 * @ingroup sgr
175 */
176typedef union {
177 GhosttySgrUnknown unknown;
178 GhosttySgrUnderline underline;
179 GhosttyColorRgb underline_color;
180 GhosttyColorPaletteIndex underline_color_256;
181 GhosttyColorRgb direct_color_fg;
182 GhosttyColorRgb direct_color_bg;
183 GhosttyColorPaletteIndex bg_8;
184 GhosttyColorPaletteIndex fg_8;
185 GhosttyColorPaletteIndex bright_bg_8;
186 GhosttyColorPaletteIndex bright_fg_8;
187 GhosttyColorPaletteIndex bg_256;
188 GhosttyColorPaletteIndex fg_256;
189 uint64_t _padding[8];
190} GhosttySgrAttributeValue;
191
192/**
193 * SGR attribute (tagged union).
194 *
195 * A complete SGR attribute with both its type tag and associated value.
196 * Always check the tag field to determine which value union member is valid.
197 *
198 * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be
199 * identified by tag alone; the value union is not used for these and
200 * the memory in the value field is undefined.
201 *
202 * @ingroup sgr
203 */
204typedef struct {
205 GhosttySgrAttributeTag tag;
206 GhosttySgrAttributeValue value;
207} GhosttySgrAttribute;
208
209/**
210 * Create a new SGR parser instance.
211 *
212 * Creates a new SGR (Select Graphic Rendition) parser using the provided
213 * allocator. The parser must be freed using ghostty_sgr_free() when
214 * no longer needed.
215 *
216 * @param allocator Pointer to the allocator to use for memory management, or
217 * NULL to use the default allocator
218 * @param parser Pointer to store the created parser handle
219 * @return GHOSTTY_SUCCESS on success, or an error code on failure
220 *
221 * @ingroup sgr
222 */
223GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator,
224 GhosttySgrParser* parser);
225
226/**
227 * Free an SGR parser instance.
228 *
229 * Releases all resources associated with the SGR parser. After this call,
230 * the parser handle becomes invalid and must not be used. This includes
231 * any attributes previously returned by ghostty_sgr_next().
232 *
233 * @param parser The parser handle to free (may be NULL)
234 *
235 * @ingroup sgr
236 */
237void ghostty_sgr_free(GhosttySgrParser parser);
238
239/**
240 * Reset an SGR parser instance to the beginning of the parameter list.
241 *
242 * Resets the parser's iteration state without clearing the parameters.
243 * After calling this, ghostty_sgr_next() will start from the beginning
244 * of the parameter list again.
245 *
246 * @param parser The parser handle to reset, must not be NULL
247 *
248 * @ingroup sgr
249 */
250void ghostty_sgr_reset(GhosttySgrParser parser);
251
252/**
253 * Set SGR parameters for parsing.
254 *
255 * Sets the SGR parameter list to parse. Parameters are the numeric values
256 * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}).
257 *
258 * The separators array optionally specifies the separator type for each
259 * parameter position. Each byte should be either ';' for semicolon or ':'
260 * for colon. This is needed for certain color formats that use colon
261 * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator
262 * values are treated as semicolons. The separators array must have the same
263 * length as the params array, if it is not NULL.
264 *
265 * If separators is NULL, all parameters are assumed to be semicolon-separated.
266 *
267 * This function makes an internal copy of the parameter and separator data,
268 * so the caller can safely free or modify the input arrays after this call.
269 *
270 * After calling this function, the parser is automatically reset and ready
271 * to iterate from the beginning.
272 *
273 * @param parser The parser handle, must not be NULL
274 * @param params Array of SGR parameter values
275 * @param separators Optional array of separator characters (';' or ':'), or
276 * NULL
277 * @param len Number of parameters (and separators if provided)
278 * @return GHOSTTY_SUCCESS on success, or an error code on failure
279 *
280 * @ingroup sgr
281 */
282GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser,
283 const uint16_t* params,
284 const char* separators,
285 size_t len);
286
287/**
288 * Get the next SGR attribute.
289 *
290 * Parses and returns the next attribute from the parameter list.
291 * Call this function repeatedly until it returns false to process
292 * all attributes in the sequence.
293 *
294 * @param parser The parser handle, must not be NULL
295 * @param attr Pointer to store the next attribute
296 * @return true if an attribute was returned, false if no more attributes
297 *
298 * @ingroup sgr
299 */
300bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr);
301
302/**
303 * Get the full parameter list from an unknown SGR attribute.
304 *
305 * This function retrieves the full parameter list that was provided to the
306 * parser when an unknown attribute was encountered. Primarily useful in
307 * WebAssembly environments where accessing struct fields directly is difficult.
308 *
309 * @param unknown The unknown attribute data
310 * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
311 * @return The length of the full parameter array
312 *
313 * @ingroup sgr
314 */
315size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown,
316 const uint16_t** ptr);
317
318/**
319 * Get the partial parameter list from an unknown SGR attribute.
320 *
321 * This function retrieves the partial parameter list where parsing stopped
322 * when an unknown attribute was encountered. Primarily useful in WebAssembly
323 * environments where accessing struct fields directly is difficult.
324 *
325 * @param unknown The unknown attribute data
326 * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
327 * @return The length of the partial parameter array
328 *
329 * @ingroup sgr
330 */
331size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown,
332 const uint16_t** ptr);
333
334/**
335 * Get the tag from an SGR attribute.
336 *
337 * This function extracts the tag that identifies which type of attribute
338 * this is. Primarily useful in WebAssembly environments where accessing
339 * struct fields directly is difficult.
340 *
341 * @param attr The SGR attribute
342 * @return The attribute tag
343 *
344 * @ingroup sgr
345 */
346GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr);
347
348/**
349 * Get the value from an SGR attribute.
350 *
351 * This function returns a pointer to the value union from an SGR attribute. Use
352 * the tag to determine which field of the union is valid. Primarily useful in
353 * WebAssembly environments where accessing struct fields directly is difficult.
354 *
355 * @param attr Pointer to the SGR attribute
356 * @return Pointer to the attribute value union
357 *
358 * @ingroup sgr
359 */
360GhosttySgrAttributeValue* ghostty_sgr_attribute_value(
361 GhosttySgrAttribute* attr);
362
363#ifdef __wasm__
364/**
365 * Allocate memory for an SGR attribute (WebAssembly only).
366 *
367 * This is a convenience function for WebAssembly environments to allocate
368 * memory for an SGR attribute structure that can be passed to ghostty_sgr_next.
369 *
370 * @return Pointer to the allocated attribute structure
371 *
372 * @ingroup wasm
373 */
374GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void);
375
376/**
377 * Free memory for an SGR attribute (WebAssembly only).
378 *
379 * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute.
380 *
381 * @param attr Pointer to the attribute structure to free
382 *
383 * @ingroup wasm
384 */
385void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr);
386#endif
387
388#ifdef __cplusplus
389}
390#endif
391
392/** @} */
393
394#endif /* GHOSTTY_VT_SGR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h created+159
...@@ -0,0 +1,159 @@
1/**
2 * @file wasm.h
3 *
4 * WebAssembly utility functions for libghostty-vt.
5 */
6
7#ifndef GHOSTTY_VT_WASM_H
8#define GHOSTTY_VT_WASM_H
9
10#ifdef __wasm__
11
12#include <stddef.h>
13#include <stdint.h>
14
15/** @defgroup wasm WebAssembly Utilities
16 *
17 * Convenience functions for allocating various types in WebAssembly builds.
18 * **These are only available the libghostty-vt wasm module.**
19 *
20 * Ghostty relies on pointers to various types for ABI compatibility, and
21 * creating those pointers in Wasm can be tedious. These functions provide
22 * a purely additive set of utilities that simplify memory management in
23 * Wasm environments without changing the core C library API.
24 *
25 * @note These functions always use the default allocator. If you need
26 * custom allocation strategies, you should allocate types manually using
27 * your custom allocator. This is a very rare use case in the WebAssembly
28 * world so these are optimized for simplicity.
29 *
30 * ## Example Usage
31 *
32 * Here's a simple example of using the Wasm utilities with the key encoder:
33 *
34 * @code
35 * const { exports } = wasmInstance;
36 * const view = new DataView(wasmMemory.buffer);
37 *
38 * // Create key encoder
39 * const encoderPtr = exports.ghostty_wasm_alloc_opaque();
40 * exports.ghostty_key_encoder_new(null, encoderPtr);
41 * const encoder = view.getUint32(encoder, true);
42 *
43 * // Configure encoder with Kitty protocol flags
44 * const flagsPtr = exports.ghostty_wasm_alloc_u8();
45 * view.setUint8(flagsPtr, 0x1F);
46 * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr);
47 *
48 * // Allocate output buffer and size pointer
49 * const bufferSize = 32;
50 * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize);
51 * const writtenPtr = exports.ghostty_wasm_alloc_usize();
52 *
53 * // Encode the key event
54 * exports.ghostty_key_encoder_encode(
55 * encoder, eventPtr, bufPtr, bufferSize, writtenPtr
56 * );
57 *
58 * // Read encoded output
59 * const bytesWritten = view.getUint32(writtenPtr, true);
60 * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten);
61 * @endcode
62 *
63 * @remark The code above is pretty ugly! This is the lowest level interface
64 * to the libghostty-vt Wasm module. In practice, this should be wrapped
65 * in a higher-level API that abstracts away all this.
66 *
67 * @{
68 */
69
70/**
71 * Allocate an opaque pointer. This can be used for any opaque pointer
72 * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc.
73 *
74 * @return Pointer to allocated opaque pointer, or NULL if allocation failed
75 * @ingroup wasm
76 */
77void** ghostty_wasm_alloc_opaque(void);
78
79/**
80 * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque().
81 *
82 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
83 * @ingroup wasm
84 */
85void ghostty_wasm_free_opaque(void **ptr);
86
87/**
88 * Allocate an array of uint8_t values.
89 *
90 * @param len Number of uint8_t elements to allocate
91 * @return Pointer to allocated array, or NULL if allocation failed
92 * @ingroup wasm
93 */
94uint8_t* ghostty_wasm_alloc_u8_array(size_t len);
95
96/**
97 * Free an array allocated by ghostty_wasm_alloc_u8_array().
98 *
99 * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
100 * @param len Length of the array (must match the length passed to alloc)
101 * @ingroup wasm
102 */
103void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len);
104
105/**
106 * Allocate an array of uint16_t values.
107 *
108 * @param len Number of uint16_t elements to allocate
109 * @return Pointer to allocated array, or NULL if allocation failed
110 * @ingroup wasm
111 */
112uint16_t* ghostty_wasm_alloc_u16_array(size_t len);
113
114/**
115 * Free an array allocated by ghostty_wasm_alloc_u16_array().
116 *
117 * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
118 * @param len Length of the array (must match the length passed to alloc)
119 * @ingroup wasm
120 */
121void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len);
122
123/**
124 * Allocate a single uint8_t value.
125 *
126 * @return Pointer to allocated uint8_t, or NULL if allocation failed
127 * @ingroup wasm
128 */
129uint8_t* ghostty_wasm_alloc_u8(void);
130
131/**
132 * Free a uint8_t allocated by ghostty_wasm_alloc_u8().
133 *
134 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
135 * @ingroup wasm
136 */
137void ghostty_wasm_free_u8(uint8_t *ptr);
138
139/**
140 * Allocate a single size_t value.
141 *
142 * @return Pointer to allocated size_t, or NULL if allocation failed
143 * @ingroup wasm
144 */
145size_t* ghostty_wasm_alloc_usize(void);
146
147/**
148 * Free a size_t allocated by ghostty_wasm_alloc_usize().
149 *
150 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
151 * @ingroup wasm
152 */
153void ghostty_wasm_free_usize(size_t *ptr);
154
155/** @} */
156
157#endif /* __wasm__ */
158
159#endif /* GHOSTTY_VT_WASM_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/module.modulemap created+7
...@@ -0,0 +1,7 @@
1// This makes Ghostty available to the XCode build for the macOS app.
2// We append "Kit" to it not to be cute, but because targets have to have
3// unique names and we use Ghostty for other things.
4module GhosttyKit {
5 umbrella header "ghostty.h"
6 export *
7}
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a created
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a differ
apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION created+1
...@@ -0,0 +1 @@
1d36c3b8dffd0d756dd5e5f4933962f774a0e6753
apps/mobile/modules/t3-terminal/android/build.gradle created+19
...@@ -0,0 +1,19 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.terminal'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3terminal'
9 compileSdk rootProject.ext.compileSdkVersion
10
11 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion
14 }
15}
16
17dependencies {
18 implementation project(':expo-modules-core')
19}
apps/mobile/modules/t3-terminal/android/src/main/AndroidManifest.xml created+1
...@@ -0,0 +1 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt created+46
...@@ -0,0 +1,46 @@
1package expo.modules.t3terminal
2
3import expo.modules.kotlin.modules.Module
4import expo.modules.kotlin.modules.ModuleDefinition
5
6class T3TerminalModule : Module() {
7 override fun definition() = ModuleDefinition {
8 Name("T3TerminalSurface")
9
10 View(T3TerminalView::class) {
11 Prop("terminalKey") { view: T3TerminalView, terminalKey: String ->
12 view.terminalKey = terminalKey
13 }
14
15 Prop("initialBuffer") { view: T3TerminalView, initialBuffer: String ->
16 view.initialBuffer = initialBuffer
17 }
18
19 Prop("fontSize") { view: T3TerminalView, fontSize: Double ->
20 view.fontSize = fontSize.toFloat()
21 }
22
23 Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->
24 view.appearanceScheme = appearanceScheme
25 }
26
27 Prop("themeConfig") { view: T3TerminalView, themeConfig: String ->
28 view.themeConfig = themeConfig
29 }
30
31 Prop("backgroundColor") { view: T3TerminalView, backgroundColor: String ->
32 view.backgroundColorHex = backgroundColor
33 }
34
35 Prop("foregroundColor") { view: T3TerminalView, foregroundColor: String ->
36 view.foregroundColorHex = foregroundColor
37 }
38
39 Prop("mutedForegroundColor") { view: T3TerminalView, mutedForegroundColor: String ->
40 view.mutedForegroundColorHex = mutedForegroundColor
41 }
42
43 Events("onInput", "onResize")
44 }
45 }
46}
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt created+211
...@@ -0,0 +1,211 @@
1package expo.modules.t3terminal
2
3import android.content.Context
4import android.graphics.Color
5import android.graphics.Typeface
6import android.view.View
7import android.view.ViewGroup
8import android.view.inputmethod.InputMethodManager
9import android.view.inputmethod.EditorInfo
10import android.widget.EditText
11import android.widget.LinearLayout
12import android.widget.ScrollView
13import android.widget.TextView
14import androidx.core.widget.doAfterTextChanged
15import expo.modules.kotlin.AppContext
16import expo.modules.kotlin.views.ExpoView
17import expo.modules.kotlin.viewevent.EventDispatcher
18import kotlin.math.max
19import kotlin.math.min
20
21class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
22 private val container = LinearLayout(context)
23 private val scrollView = ScrollView(context)
24 private val textView = TextView(context)
25 private val inputView = EditText(context)
26 private val onInput by EventDispatcher()
27 private val onResize by EventDispatcher()
28 private var lastWidth = 0
29 private var lastHeight = 0
30 private var clearingInput = false
31 private var backgroundColorValue = Color.parseColor("#24292E")
32 private var foregroundColorValue = Color.parseColor("#D1D5DA")
33 private var mutedForegroundColorValue = Color.parseColor("#959DA5")
34
35 var terminalKey: String = ""
36 set(value) {
37 field = value
38 contentDescription = "t3-terminal-$value"
39 }
40
41 var initialBuffer: String = ""
42 set(value) {
43 field = value
44 textView.text = value.ifEmpty { "$ " }
45 scrollView.post {
46 scrollView.fullScroll(View.FOCUS_DOWN)
47 }
48 }
49
50 var fontSize: Float = 10f
51 set(value) {
52 field = value
53 textView.textSize = value
54 inputView.textSize = max(value, 13f)
55 emitResize()
56 }
57
58 var appearanceScheme: String = "dark"
59 set(value) {
60 field = value
61 }
62
63 var themeConfig: String = ""
64
65 var backgroundColorHex: String = "#24292E"
66 set(value) {
67 field = value
68 backgroundColorValue = parseColor(value, backgroundColorValue)
69 applyTheme()
70 }
71
72 var foregroundColorHex: String = "#D1D5DA"
73 set(value) {
74 field = value
75 foregroundColorValue = parseColor(value, foregroundColorValue)
76 applyTheme()
77 }
78
79 var mutedForegroundColorHex: String = "#959DA5"
80 set(value) {
81 field = value
82 mutedForegroundColorValue = parseColor(value, mutedForegroundColorValue)
83 applyTheme()
84 }
85
86 init {
87 applyTheme()
88 container.orientation = LinearLayout.VERTICAL
89 textView.typeface = Typeface.MONOSPACE
90 textView.textSize = fontSize
91 textView.setPadding(8, 8, 8, 8)
92 textView.text = "$ "
93
94 inputView.setSingleLine(true)
95 inputView.setTextColor(Color.TRANSPARENT)
96 inputView.setHintTextColor(Color.TRANSPARENT)
97 inputView.setBackgroundColor(Color.TRANSPARENT)
98 inputView.typeface = Typeface.MONOSPACE
99 inputView.textSize = max(fontSize, 13f)
100 inputView.hint = ""
101 inputView.alpha = 0.02f
102 inputView.imeOptions = EditorInfo.IME_ACTION_SEND
103 inputView.setPadding(0, 0, 0, 0)
104 inputView.setOnFocusChangeListener { _, hasFocus ->
105 if (hasFocus) {
106 showKeyboard()
107 }
108 }
109 inputView.setOnEditorActionListener { view, actionId, _ ->
110 if (actionId != EditorInfo.IME_ACTION_SEND) return@setOnEditorActionListener false
111 onInput(mapOf("data" to "\n"))
112 true
113 }
114 inputView.setOnKeyListener { _, keyCode, event ->
115 if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false
116 when (keyCode) {
117 android.view.KeyEvent.KEYCODE_DEL -> {
118 onInput(mapOf("data" to "\u007F"))
119 true
120 }
121 else -> false
122 }
123 }
124 inputView.doAfterTextChanged { editable ->
125 if (clearingInput) return@doAfterTextChanged
126 val text = editable?.toString().orEmpty()
127 if (text.isEmpty()) return@doAfterTextChanged
128 onInput(mapOf("data" to text))
129 clearingInput = true
130 inputView.text?.clear()
131 clearingInput = false
132 }
133
134 textView.setOnClickListener { requestKeyboardFocus() }
135 scrollView.setOnClickListener { requestKeyboardFocus() }
136 container.setOnClickListener { requestKeyboardFocus() }
137 isClickable = true
138 setOnClickListener { requestKeyboardFocus() }
139
140 scrollView.addView(
141 textView,
142 LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT),
143 )
144 container.addView(
145 scrollView,
146 LinearLayout.LayoutParams(
147 ViewGroup.LayoutParams.MATCH_PARENT,
148 0,
149 1f,
150 ),
151 )
152 container.addView(
153 inputView,
154 LinearLayout.LayoutParams(1, 1),
155 )
156 addView(
157 container,
158 LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
159 )
160
161 post {
162 requestKeyboardFocus()
163 }
164 }
165
166 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
167 super.onSizeChanged(width, height, oldWidth, oldHeight)
168 if (width == lastWidth && height == lastHeight) return
169 lastWidth = width
170 lastHeight = height
171 emitResize()
172 }
173
174 private fun emitResize() {
175 if (width <= 0 || height <= 0) return
176 val density = resources.displayMetrics.scaledDensity
177 val fontPx = max(fontSize * density, 1f)
178 val cols = max(20, min(400, (width / (fontPx * 0.62f)).toInt()))
179 val terminalHeight = max(height - inputView.height, 0)
180 val rows = max(5, min(200, (terminalHeight / (fontPx * 1.35f)).toInt()))
181 onResize(mapOf("cols" to cols, "rows" to rows))
182 }
183
184 private fun requestKeyboardFocus() {
185 inputView.requestFocus()
186 showKeyboard()
187 }
188
189 private fun applyTheme() {
190 setBackgroundColor(backgroundColorValue)
191 container.setBackgroundColor(backgroundColorValue)
192 scrollView.setBackgroundColor(backgroundColorValue)
193 textView.setTextColor(foregroundColorValue)
194 textView.setBackgroundColor(backgroundColorValue)
195 inputView.setTextColor(Color.TRANSPARENT)
196 inputView.setHintTextColor(mutedForegroundColorValue)
197 inputView.setBackgroundColor(Color.TRANSPARENT)
198 }
199
200 private fun parseColor(value: String, fallback: Int): Int =
201 try {
202 Color.parseColor(value)
203 } catch (_: IllegalArgumentException) {
204 fallback
205 }
206
207 private fun showKeyboard() {
208 val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
209 imm?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT)
210 }
211}
apps/mobile/modules/t3-terminal/expo-module.config.json created+10
...@@ -0,0 +1,10 @@
1{
2 "platforms": ["apple", "android"],
3 "apple": {
4 "modules": ["T3TerminalModule"],
5 "podspecPath": "T3TerminalNative.podspec"
6 },
7 "android": {
8 "modules": ["expo.modules.t3terminal.T3TerminalModule"]
9 }
10}
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift created+43
...@@ -0,0 +1,43 @@
1import ExpoModulesCore
2
3public class T3TerminalModule: Module {
4 public func definition() -> ModuleDefinition {
5 Name("T3TerminalSurface")
6
7 View(T3TerminalView.self) {
8 Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in
9 view.terminalKey = terminalKey
10 }
11
12 Prop("initialBuffer") { (view: T3TerminalView, initialBuffer: String) in
13 view.initialBuffer = initialBuffer
14 }
15
16 Prop("fontSize") { (view: T3TerminalView, fontSize: Double) in
17 view.fontSize = CGFloat(fontSize)
18 }
19
20 Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in
21 view.appearanceScheme = appearanceScheme
22 }
23
24 Prop("themeConfig") { (view: T3TerminalView, themeConfig: String) in
25 view.themeConfig = themeConfig
26 }
27
28 Prop("backgroundColor") { (view: T3TerminalView, backgroundColor: String) in
29 view.backgroundColorHex = backgroundColor
30 }
31
32 Prop("foregroundColor") { (view: T3TerminalView, foregroundColor: String) in
33 view.foregroundColorHex = foregroundColor
34 }
35
36 Prop("mutedForegroundColor") { (view: T3TerminalView, mutedForegroundColor: String) in
37 view.mutedForegroundColorHex = mutedForegroundColor
38 }
39
40 Events("onInput", "onResize")
41 }
42 }
43}
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift created+560
...@@ -0,0 +1,560 @@
1import ExpoModulesCore
2import Foundation
3import GhosttyKit
4import QuartzCore
5import UIKit
6
7private enum GhosttyRuntime {
8 private static let lock = NSLock()
9 private static var initialized = false
10
11 static func ensureInitialized() -> Bool {
12 lock.lock()
13 defer { lock.unlock() }
14
15 if initialized {
16 return true
17 }
18
19 let result = ghostty_init(0, nil)
20 initialized = result == GHOSTTY_SUCCESS
21 return initialized
22 }
23}
24
25private final class TerminalInputField: UITextField {
26 var onDeleteBackward: (() -> Void)?
27
28 override func deleteBackward() {
29 onDeleteBackward?()
30 super.deleteBackward()
31 }
32}
33
34private enum TerminalAppearanceScheme: String {
35 case light
36 case dark
37
38 init(value: String) {
39 self = TerminalAppearanceScheme(rawValue: value) ?? .dark
40 }
41
42 var ghosttyColorScheme: ghostty_color_scheme_e {
43 switch self {
44 case .light:
45 return GHOSTTY_COLOR_SCHEME_LIGHT
46 case .dark:
47 return GHOSTTY_COLOR_SCHEME_DARK
48 }
49 }
50}
51
52private extension UIColor {
53 convenience init(hexString: String) {
54 let sanitized = hexString.replacingOccurrences(of: "#", with: "")
55 let value = Int(sanitized, radix: 16) ?? 0
56 self.init(
57 red: CGFloat((value >> 16) & 0xFF) / 255,
58 green: CGFloat((value >> 8) & 0xFF) / 255,
59 blue: CGFloat(value & 0xFF) / 255,
60 alpha: 1
61 )
62 }
63}
64
65public final class T3TerminalView: ExpoView, UITextFieldDelegate {
66 private static let minimumVerticalScrollStepPoints: CGFloat = 18
67 private static let verticalScrollStepMultiplier: CGFloat = 1.15
68
69 private let terminalViewport = UIView()
70 private let inputField = TerminalInputField()
71 private let focusTapGesture = UITapGestureRecognizer()
72 private let scrollPanGesture = UIPanGestureRecognizer()
73 private var lastViewportSize: CGSize = .zero
74 private var lastContentScale: CGFloat = 0
75 private var lastReportedGrid: (cols: Int, rows: Int)?
76 private var lastAppliedBuffer = ""
77 private var pendingVerticalScrollPoints: CGFloat = 0
78 private var app: ghostty_app_t?
79 private var surface: ghostty_surface_t?
80 private var isCreatingSurface = false
81 private var surfaceCreationFailed = false
82 private var appearance = TerminalAppearanceScheme.dark
83 private var backgroundColorValue = UIColor(hexString: "#24292e")
84
85 let onInput = EventDispatcher()
86 let onResize = EventDispatcher()
87
88 var terminalKey: String = "" {
89 didSet {
90 accessibilityIdentifier = "t3-terminal-\(terminalKey)"
91 if oldValue != terminalKey {
92 resetSurface()
93 }
94 }
95 }
96
97 var initialBuffer: String = "" {
98 didSet {
99 applyRemoteBuffer(initialBuffer)
100 }
101 }
102
103 var fontSize: CGFloat = 10 {
104 didSet {
105 guard oldValue != fontSize else { return }
106 inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
107 refreshSurface()
108 }
109 }
110
111 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
112 didSet {
113 guard oldValue != appearanceScheme else { return }
114 appearance = TerminalAppearanceScheme(value: appearanceScheme)
115 refreshSurface()
116 }
117 }
118
119 var themeConfig: String = "" {
120 didSet {
121 guard oldValue != themeConfig else { return }
122 refreshSurface()
123 }
124 }
125
126 var backgroundColorHex: String = "#24292e" {
127 didSet {
128 backgroundColorValue = UIColor(hexString: backgroundColorHex)
129 applyTheme()
130 }
131 }
132
133 var foregroundColorHex: String = "#d1d5da"
134 var mutedForegroundColorHex: String = "#959da5"
135
136 public required init(appContext: AppContext? = nil) {
137 super.init(appContext: appContext)
138
139 applyTheme()
140 clipsToBounds = true
141 contentScaleFactor = UIScreen.main.scale
142
143 terminalViewport.clipsToBounds = true
144 terminalViewport.contentScaleFactor = contentScaleFactor
145 terminalViewport.translatesAutoresizingMaskIntoConstraints = false
146 terminalViewport.isUserInteractionEnabled = true
147
148 inputField.delegate = self
149 inputField.backgroundColor = UIColor.clear
150 inputField.textColor = UIColor.clear
151 inputField.tintColor = UIColor.clear
152 inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
153 inputField.placeholder = ""
154 inputField.autocorrectionType = .no
155 inputField.autocapitalizationType = .none
156 inputField.spellCheckingType = .no
157 inputField.smartDashesType = .no
158 inputField.smartQuotesType = .no
159 inputField.returnKeyType = .send
160 inputField.keyboardType = .asciiCapable
161 inputField.enablesReturnKeyAutomatically = false
162 inputField.translatesAutoresizingMaskIntoConstraints = false
163 inputField.alpha = 0.02
164 inputField.isAccessibilityElement = false
165 inputField.accessibilityElementsHidden = true
166 inputField.addTarget(self, action: #selector(handleInputEditingDidBegin), for: .editingDidBegin)
167 inputField.onDeleteBackward = { [weak self] in
168 self?.emitInput("\u{7F}")
169 }
170
171 focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
172 terminalViewport.addGestureRecognizer(focusTapGesture)
173 scrollPanGesture.addTarget(self, action: #selector(handleViewportPan(_:)))
174 scrollPanGesture.maximumNumberOfTouches = 1
175 scrollPanGesture.cancelsTouchesInView = false
176 terminalViewport.addGestureRecognizer(scrollPanGesture)
177
178 addSubview(terminalViewport)
179 addSubview(inputField)
180
181 NSLayoutConstraint.activate([
182 terminalViewport.leadingAnchor.constraint(equalTo: leadingAnchor),
183 terminalViewport.trailingAnchor.constraint(equalTo: trailingAnchor),
184 terminalViewport.topAnchor.constraint(equalTo: topAnchor),
185 terminalViewport.bottomAnchor.constraint(equalTo: bottomAnchor),
186
187 inputField.trailingAnchor.constraint(equalTo: trailingAnchor),
188 inputField.topAnchor.constraint(equalTo: bottomAnchor, constant: 8),
189 inputField.widthAnchor.constraint(equalToConstant: 1),
190 inputField.heightAnchor.constraint(equalToConstant: 1),
191 ])
192 }
193
194 deinit {
195 destroySurface()
196 }
197
198 public override func layoutSubviews() {
199 super.layoutSubviews()
200 updateContentScale()
201
202 let viewportSize = terminalViewport.bounds.size
203 if surface == nil {
204 createSurfaceIfPossible()
205 }
206
207 guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else {
208 return
209 }
210
211 lastViewportSize = viewportSize
212 lastContentScale = contentScaleFactor
213 resizeSurface()
214 }
215
216 public override func didMoveToWindow() {
217 super.didMoveToWindow()
218
219 guard window != nil else { return }
220 DispatchQueue.main.async { [weak self] in
221 self?.requestKeyboardFocus()
222 }
223 }
224
225 public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
226 if !string.isEmpty {
227 emitInput(string)
228 return false
229 }
230
231 return false
232 }
233
234 public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
235 emitInput("\n")
236 textField.text = ""
237 return false
238 }
239
240 @objc
241 private func handleViewportTap() {
242 requestKeyboardFocus()
243 }
244
245 @objc
246 private func handleViewportPan(_ gesture: UIPanGestureRecognizer) {
247 guard let surface else { return }
248
249 let location = gesture.location(in: terminalViewport)
250 ghostty_surface_mouse_pos(
251 surface,
252 Double(location.x * contentScaleFactor),
253 Double(location.y * contentScaleFactor),
254 GHOSTTY_MODS_NONE
255 )
256
257 switch gesture.state {
258 case .began:
259 pendingVerticalScrollPoints = 0
260 gesture.setTranslation(.zero, in: terminalViewport)
261 case .changed:
262 let translation = gesture.translation(in: terminalViewport)
263 let stepSize = max(
264 fontSize * Self.verticalScrollStepMultiplier,
265 Self.minimumVerticalScrollStepPoints
266 )
267 let totalVerticalPoints = pendingVerticalScrollPoints + translation.y
268 let verticalSteps = Int(totalVerticalPoints / stepSize)
269 pendingVerticalScrollPoints = totalVerticalPoints - (CGFloat(verticalSteps) * stepSize)
270
271 guard verticalSteps != 0 else {
272 gesture.setTranslation(.zero, in: terminalViewport)
273 return
274 }
275
276 ghostty_surface_mouse_scroll(surface, 0, Double(verticalSteps), 0)
277 redrawSurface()
278 gesture.setTranslation(.zero, in: terminalViewport)
279 default:
280 pendingVerticalScrollPoints = 0
281 gesture.setTranslation(.zero, in: terminalViewport)
282 }
283 }
284
285 @objc
286 private func handleInputEditingDidBegin() {
287 textInputModeDidChange()
288 }
289
290 private func createSurfaceIfPossible() {
291 guard surface == nil, app == nil, !isCreatingSurface, !surfaceCreationFailed else { return }
292 guard terminalViewport.bounds.width > 0, terminalViewport.bounds.height > 0 else { return }
293 guard GhosttyRuntime.ensureInitialized() else {
294 surfaceCreationFailed = true
295 return
296 }
297
298 isCreatingSurface = true
299 defer { isCreatingSurface = false }
300
301 var runtimeConfig = ghostty_runtime_config_s(
302 userdata: Unmanaged.passUnretained(self).toOpaque(),
303 supports_selection_clipboard: false,
304 wakeup_cb: { _ in },
305 action_cb: { _, _, _ in false },
306 read_clipboard_cb: { _, _, _ in false },
307 confirm_read_clipboard_cb: { _, _, _, _ in },
308 write_clipboard_cb: { _, _, _, _, _ in },
309 close_surface_cb: { _, _ in }
310 )
311
312 guard let config = ghostty_config_new() else {
313 surfaceCreationFailed = true
314 return
315 }
316 loadThemeConfig(into: config)
317 ghostty_config_finalize(config)
318 defer { ghostty_config_free(config) }
319
320 guard let createdApp = ghostty_app_new(&runtimeConfig, config) else {
321 surfaceCreationFailed = true
322 return
323 }
324
325 var surfaceConfig = ghostty_surface_config_new()
326 surfaceConfig.platform_tag = GHOSTTY_PLATFORM_IOS
327 surfaceConfig.platform.ios.uiview = Unmanaged.passUnretained(terminalViewport).toOpaque()
328 surfaceConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
329 surfaceConfig.scale_factor = Double(contentScaleFactor)
330 surfaceConfig.font_size = Float(fontSize)
331 surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW
332 surfaceConfig.use_custom_io = true
333
334 guard let createdSurface = ghostty_surface_new(createdApp, &surfaceConfig) else {
335 ghostty_app_free(createdApp)
336 surfaceCreationFailed = true
337 return
338 }
339
340 app = createdApp
341 surface = createdSurface
342 ghostty_app_set_color_scheme(createdApp, appearance.ghosttyColorScheme)
343 ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme)
344 setupWriteCallback()
345 resizeSurface()
346 feedBuffer(initialBuffer)
347 }
348
349 private func resetSurface() {
350 destroySurface()
351 lastAppliedBuffer = ""
352 lastViewportSize = .zero
353 lastContentScale = 0
354 lastReportedGrid = nil
355 surfaceCreationFailed = false
356 setNeedsLayout()
357 }
358
359 private func refreshSurface() {
360 resetSurface()
361 createSurfaceIfPossible()
362 }
363
364 private func destroySurface() {
365 if let surface {
366 ghostty_surface_set_write_callback(surface, nil, nil)
367 ghostty_surface_free(surface)
368 }
369 if let app {
370 ghostty_app_free(app)
371 }
372 surface = nil
373 app = nil
374 }
375
376 private func applyRemoteBuffer(_ buffer: String) {
377 guard surface != nil else {
378 createSurfaceIfPossible()
379 return
380 }
381
382 if buffer.hasPrefix(lastAppliedBuffer) {
383 let suffix = String(buffer.dropFirst(lastAppliedBuffer.count))
384 feedData(Data(suffix.utf8))
385 lastAppliedBuffer = buffer
386 return
387 }
388
389 resetSurface()
390 createSurfaceIfPossible()
391 }
392
393 private func feedBuffer(_ buffer: String) {
394 guard !buffer.isEmpty else { return }
395 feedData(Data(buffer.utf8))
396 lastAppliedBuffer = buffer
397 }
398
399 private func feedData(_ data: Data) {
400 guard let surface, !data.isEmpty else { return }
401
402 data.withUnsafeBytes { buffer in
403 guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
404 return
405 }
406 ghostty_surface_feed_data(surface, pointer, buffer.count)
407 }
408
409 redrawSurface()
410 }
411
412 private func setupWriteCallback() {
413 guard let surface else { return }
414
415 let userdata = Unmanaged.passUnretained(self).toOpaque()
416 ghostty_surface_set_write_callback(surface, { userdata, data, len in
417 guard let userdata, let data, len > 0 else { return }
418 let view = Unmanaged<T3TerminalView>.fromOpaque(userdata).takeUnretainedValue()
419 let bytes = Data(bytes: data, count: len)
420 guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }
421
422 DispatchQueue.main.async {
423 view.onInput(["data": input])
424 }
425 }, userdata)
426 }
427
428 private func resizeSurface() {
429 guard let surface else {
430 emitEstimatedResize()
431 return
432 }
433
434 let scale = contentScaleFactor
435 let width = UInt32(max(floor(terminalViewport.bounds.width * scale), 1))
436 let height = UInt32(max(floor(terminalViewport.bounds.height * scale), 1))
437
438 terminalViewport.contentScaleFactor = scale
439 ghostty_surface_set_content_scale(surface, Double(scale), Double(scale))
440 ghostty_surface_set_size(surface, width, height)
441 ghostty_surface_set_occlusion(surface, window != nil)
442 configureIOSurfaceLayers()
443 redrawSurface()
444 emitGhosttyResize()
445 }
446
447 private func redrawSurface() {
448 guard let surface else { return }
449 ghostty_surface_refresh(surface)
450 ghostty_surface_draw(surface)
451 markIOSurfaceLayersForDisplay()
452 emitGhosttyResize()
453 }
454
455 private func emitGhosttyResize() {
456 guard let surface else {
457 emitEstimatedResize()
458 return
459 }
460
461 let size = ghostty_surface_size(surface)
462 let cols = max(1, Int(size.columns))
463 let rows = max(1, Int(size.rows))
464 emitResize(cols: cols, rows: rows)
465 }
466
467 private func emitEstimatedResize() {
468 guard bounds.width > 0, bounds.height > 0 else { return }
469
470 let cellWidth = max(fontSize * 0.62, 1)
471 let cellHeight = max(fontSize * 1.35, 1)
472 let cols = max(20, min(400, Int(bounds.width / cellWidth)))
473 let terminalHeight = max(bounds.height, 0)
474 let rows = max(5, min(200, Int(terminalHeight / cellHeight)))
475 emitResize(cols: cols, rows: rows)
476 }
477
478 private func emitResize(cols: Int, rows: Int) {
479 guard lastReportedGrid?.cols != cols || lastReportedGrid?.rows != rows else {
480 return
481 }
482
483 lastReportedGrid = (cols, rows)
484 onResize([
485 "cols": cols,
486 "rows": rows,
487 ])
488 }
489
490 private func updateContentScale() {
491 let scale = window?.screen.scale ?? UIScreen.main.scale
492 if contentScaleFactor != scale {
493 contentScaleFactor = scale
494 }
495 }
496
497 private func requestKeyboardFocus() {
498 guard window != nil else { return }
499 inputField.becomeFirstResponder()
500 textInputModeDidChange()
501 }
502
503 private func emitInput(_ data: String) {
504 guard !data.isEmpty else { return }
505 onInput(["data": data])
506 }
507
508 private func textInputModeDidChange() {
509 guard let app else { return }
510 ghostty_app_keyboard_changed(app)
511 }
512
513 private func configureIOSurfaceLayers() {
514 let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size)
515 CATransaction.begin()
516 CATransaction.setDisableActions(true)
517 terminalViewport.layer.sublayers?.forEach { sublayer in
518 sublayer.frame = targetBounds
519 sublayer.contentsScale = contentScaleFactor
520 }
521 CATransaction.commit()
522 }
523
524 private func markIOSurfaceLayersForDisplay() {
525 terminalViewport.layer.setNeedsDisplay()
526 terminalViewport.layer.sublayers?.forEach { layer in
527 layer.setNeedsDisplay()
528 }
529 }
530
531 private func applyTheme() {
532 backgroundColor = backgroundColorValue
533 terminalViewport.backgroundColor = backgroundColorValue
534 }
535
536 private func loadThemeConfig(into config: ghostty_config_t) {
537 guard let path = writeThemeConfigFile() else { return }
538 path.withCString { cString in
539 ghostty_config_load_file(config, cString)
540 }
541 }
542
543 private func writeThemeConfigFile() -> String? {
544 guard !themeConfig.isEmpty else { return nil }
545 let configContents = themeConfig
546 let url = URL(fileURLWithPath: NSTemporaryDirectory())
547 .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty")
548
549 do {
550 if let existing = try? String(contentsOf: url, encoding: .utf8), existing == configContents {
551 return url.path
552 }
553
554 try configContents.write(to: url, atomically: true, encoding: .utf8)
555 return url.path
556 } catch {
557 return nil
558 }
559 }
560}
apps/mobile/modules/t3-terminal/package.json created+17
...@@ -0,0 +1,17 @@
1{
2 "name": "@t3tools/mobile-terminal-native",
3 "version": "0.0.0",
4 "private": true,
5 "expo-module": {
6 "ios": {
7 "modules": [
8 "T3TerminalModule"
9 ]
10 },
11 "android": {
12 "modules": [
13 "expo.modules.t3terminal.T3TerminalModule"
14 ]
15 }
16 }
17}
apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh created+106
...@@ -0,0 +1,106 @@
1#!/usr/bin/env bash
2
3set -euo pipefail
4
5SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
7VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty"
8
9GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/ghostty}"
10GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}"
11GHOSTTY_ZIG="${GHOSTTY_ZIG:-}"
12
13log() {
14 printf '[libghostty-ios16] %s\n' "$*"
15}
16
17die() {
18 printf '[libghostty-ios16] error: %s\n' "$*" >&2
19 exit 1
20}
21
22require_cmd() {
23 command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
24}
25
26ensure_zig() {
27 if [[ -n "${GHOSTTY_ZIG}" ]]; then
28 [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}"
29 return
30 fi
31
32 if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then
33 GHOSTTY_ZIG="$(command -v zig)"
34 return
35 fi
36
37 local cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}"
38 local archive_arch
39 archive_arch="$(uname -m)"
40 case "${archive_arch}" in
41 arm64) archive_arch="aarch64" ;;
42 x86_64) archive_arch="x86_64" ;;
43 *) die "unsupported macOS architecture for Zig download: ${archive_arch}" ;;
44 esac
45
46 GHOSTTY_ZIG="${cache_dir}/zig"
47 if [[ -x "${GHOSTTY_ZIG}" ]]; then
48 return
49 fi
50
51 require_cmd curl
52 require_cmd tar
53 mkdir -p "${cache_dir}"
54 log "downloading Zig ${GHOSTTY_ZIG_VERSION}"
55 curl -fsSL "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${archive_arch}-macos-${GHOSTTY_ZIG_VERSION}.tar.xz" \
56 | tar -xJ --strip-components=1 -C "${cache_dir}"
57}
58
59require_cmd git
60require_cmd xcodebuild
61require_cmd xcrun
62require_cmd rsync
63ensure_zig
64
65ghostty_ref="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)"
66log "using Ghostty source: ${GHOSTTY_SOURCE_DIR} @ ${ghostty_ref}"
67log "using Zig: ${GHOSTTY_ZIG} ($("${GHOSTTY_ZIG}" version))"
68log "building GhosttyKit.xcframework"
69
70(
71 cd "${GHOSTTY_SOURCE_DIR}"
72 PATH="$(dirname "${GHOSTTY_ZIG}"):${PATH}" "${GHOSTTY_ZIG}" build \
73 -Dapp-runtime=none \
74 -Demit-xcframework=true \
75 -Demit-macos-app=false \
76 -Demit-exe=false \
77 -Demit-docs=false \
78 -Demit-webdata=false \
79 -Demit-helpgen=false \
80 -Demit-terminfo=false \
81 -Demit-termcap=false \
82 -Demit-themes=false \
83 -Doptimize=ReleaseFast \
84 -Dstrip \
85 -Dxcframework-target=universal
86)
87
88xcframework="${GHOSTTY_SOURCE_DIR}/macos/GhosttyKit.xcframework"
89ios_archive="${xcframework}/ios-arm64/libghostty-fat.a"
90sim_archive="${xcframework}/ios-arm64-simulator/libghostty-fat.a"
91[[ -f "${ios_archive}" ]] || die "missing built iOS archive: ${ios_archive}"
92[[ -f "${sim_archive}" ]] || die "missing built iOS simulator archive: ${sim_archive}"
93
94log "stripping iOS archives"
95xcrun strip -S -x "${ios_archive}"
96xcrun strip -S -x "${sim_archive}"
97
98log "copying iOS archives into ${VENDOR_DIR}/GhosttyKit.xcframework"
99cp "${ios_archive}" "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a"
100cp "${sim_archive}" "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a"
101rsync -a --delete "${xcframework}/ios-arm64/Headers/" \
102 "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/Headers/"
103rsync -a --delete "${xcframework}/ios-arm64-simulator/Headers/" \
104 "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/Headers/"
105
106log "done"
apps/mobile/package.json created+104
...@@ -0,0 +1,104 @@
1{
2 "name": "@t3tools/mobile",
3 "version": "0.0.0",
4 "private": true,
5 "main": "index.ts",
6 "scripts": {
7 "dev": "expo start --clear",
8 "dev:client": "APP_VARIANT=development expo start --dev-client --clear",
9 "start": "expo start",
10 "start:dev": "APP_VARIANT=development expo start",
11 "start:preview": "APP_VARIANT=preview expo start",
12 "start:prod": "APP_VARIANT=production expo start",
13 "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
14 "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
15 "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
16 "android:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
17 "eas:android:dev": "eas build --profile development -p android",
18 "eas:android:preview": "eas build --profile preview -p android",
19 "eas:android:prod": "eas build --profile production -p android",
20 "ios": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
21 "ios:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
22 "ios:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
23 "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
24 "eas:ios:dev": "eas build --profile development -p ios",
25 "eas:ios:preview": "eas build --profile preview -p ios",
26 "eas:ios:prod": "eas build --profile production -p ios",
27 "eas:dev": "eas build --profile development",
28 "eas:preview": "eas build --profile preview",
29 "eas:prod": "eas build --profile production",
30 "config:dev": "APP_VARIANT=development expo config",
31 "config:preview": "APP_VARIANT=preview expo config",
32 "config:prod": "APP_VARIANT=production expo config",
33 "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review",
34 "test": "vitest run",
35 "typecheck": "tsc --noEmit"
36 },
37 "dependencies": {
38 "@callstack/liquid-glass": "^0.7.1",
39 "@effect/atom-react": "catalog:",
40 "@expo-google-fonts/dm-sans": "^0.4.2",
41 "@expo/ui": "~56.0.8",
42 "@legendapp/list": "3.0.0-beta.44",
43 "@pierre/diffs": "catalog:",
44 "@react-native-menu/menu": "^2.0.0",
45 "@shikijs/core": "3.23.0",
46 "@shikijs/engine-javascript": "3.23.0",
47 "@shikijs/langs": "3.23.0",
48 "@shikijs/themes": "3.23.0",
49 "@t3tools/client-runtime": "workspace:*",
50 "@t3tools/contracts": "workspace:*",
51 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
52 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
53 "@t3tools/shared": "workspace:*",
54 "clsx": "^2.1.1",
55 "diff": "8.0.3",
56 "effect": "catalog:",
57 "expo": "^56.0.0",
58 "expo-build-properties": "~56.0.15",
59 "expo-camera": "~56.0.7",
60 "expo-clipboard": "~56.0.3",
61 "expo-constants": "~56.0.16",
62 "expo-crypto": "~56.0.4",
63 "expo-dev-client": "~56.0.16",
64 "expo-file-system": "~56.0.7",
65 "expo-font": "~56.0.5",
66 "expo-glass-effect": "~56.0.4",
67 "expo-haptics": "~56.0.3",
68 "expo-image-picker": "~56.0.14",
69 "expo-linking": "~56.0.12",
70 "expo-paste-input": "^0.1.15",
71 "expo-router": "~56.2.7",
72 "expo-secure-store": "~56.0.4",
73 "expo-splash-screen": "~56.0.10",
74 "expo-symbols": "~56.0.5",
75 "expo-updates": "~56.0.17",
76 "punycode": "^2.3.1",
77 "react": "19.2.3",
78 "react-dom": "19.2.3",
79 "react-native": "0.85.3",
80 "react-native-gesture-handler": "~2.31.1",
81 "react-native-image-viewing": "^0.2.2",
82 "react-native-keyboard-controller": "1.21.6",
83 "react-native-nitro-markdown": "^0.5.0",
84 "react-native-nitro-modules": "^0.35.4",
85 "react-native-reanimated": "4.3.1",
86 "react-native-safe-area-context": "~5.7.0",
87 "react-native-screens": "4.25.2",
88 "react-native-shiki-engine": "^0.3.9",
89 "react-native-svg": "15.15.4",
90 "react-native-worklets": "0.8.3",
91 "shiki": "3.23.0",
92 "tailwind-merge": "^3.5.0",
93 "uniwind": "^1.6.2"
94 },
95 "devDependencies": {
96 "@types/react": "~19.2.0",
97 "babel-preset-expo": "~56.0.0",
98 "tailwindcss": "^4.0.0",
99 "typescript": "catalog:"
100 },
101 "overrides": {
102 "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz"
103 }
104}
apps/mobile/plugins/withAndroidCleartextTraffic.cjs created+18
...@@ -0,0 +1,18 @@
1const { withAndroidManifest } = require("expo/config-plugins");
2
3module.exports = function withAndroidCleartextTraffic(config) {
4 return withAndroidManifest(config, (nextConfig) => {
5 const application = nextConfig.modResults.manifest.application?.[0];
6
7 if (application == null) {
8 throw new Error(
9 "AndroidManifest.xml is missing the application element required for cleartext traffic configuration.",
10 );
11 }
12
13 application.$ ??= {};
14 application.$["android:usesCleartextTraffic"] = "true";
15
16 return nextConfig;
17 });
18};
apps/mobile/src/app/+not-found.tsx created+43
...@@ -0,0 +1,43 @@
1import { Link } from "expo-router";
2import { Pressable, ScrollView, StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5import { AppText as Text } from "../components/AppText";
6
7export default function NotFoundRoute() {
8 const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
9 const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
10
11 return (
12 <ScrollView
13 contentInsetAdjustmentBehavior="automatic"
14 contentContainerStyle={{
15 flexGrow: 1,
16 alignItems: "center",
17 justifyContent: "center",
18 gap: 16,
19 paddingHorizontal: 24,
20 paddingVertical: 32,
21 }}
22 style={[{ flex: 1 }, screenBgStyle]}
23 >
24 <Text className="text-[28px] font-t3-bold text-foreground" selectable>
25 Route not found
26 </Text>
27 <Link href="/" asChild>
28 <Pressable
29 style={[
30 {
31 borderRadius: 999,
32 paddingHorizontal: 20,
33 paddingVertical: 14,
34 },
35 primaryBgStyle,
36 ]}
37 >
38 <Text className="text-[16px] font-t3-bold text-primary-foreground">Return home</Text>
39 </Pressable>
40 </Link>
41 </ScrollView>
42 );
43}
apps/mobile/src/app/_layout.tsx created+104
...@@ -0,0 +1,104 @@
1import "../../global.css";
2import {
3 DMSans_400Regular,
4 DMSans_500Medium,
5 DMSans_700Bold,
6 useFonts,
7} from "@expo-google-fonts/dm-sans";
8import Stack from "expo-router/stack";
9import { StatusBar, useColorScheme } from "react-native";
10import { GestureHandlerRootView } from "react-native-gesture-handler";
11import { KeyboardProvider } from "react-native-keyboard-controller";
12import { SafeAreaProvider } from "react-native-safe-area-context";
13import { useCSSVariable, useResolveClassNames } from "uniwind";
14
15import { LoadingScreen } from "../components/LoadingScreen";
16
17import {
18 useRemoteEnvironmentBootstrap,
19 useRemoteEnvironmentState,
20} from "../state/use-remote-environment-registry";
21import { RegistryContext } from "@effect/atom-react";
22import { appAtomRegistry } from "../state/atom-registry";
23
24function AppNavigator() {
25 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
26 const colorScheme = useColorScheme();
27 const statusBarBg = useCSSVariable("--color-status-bar");
28 const sheetStyle = useResolveClassNames("bg-sheet");
29
30 const newTaskScreenOptions = {
31 contentStyle: sheetStyle,
32 gestureEnabled: true,
33 headerShown: false,
34 presentation: "formSheet" as const,
35 sheetAllowedDetents: [0.92],
36 sheetGrabberVisible: true,
37 };
38
39 const connectionSheetScreenOptions = {
40 contentStyle: sheetStyle,
41 gestureEnabled: true,
42 headerShown: false,
43 presentation: "formSheet" as const,
44 sheetAllowedDetents: [0.55, 0.7],
45 sheetGrabberVisible: true,
46 };
47
48 if (isLoadingSavedConnection) {
49 return <LoadingScreen message="Loading remote workspace…" />;
50 }
51
52 return (
53 <>
54 <StatusBar
55 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
56 backgroundColor={statusBarBg as string}
57 translucent
58 />
59 <Stack screenOptions={{ headerShown: false }}>
60 <Stack.Screen
61 name="index"
62 options={{
63 contentStyle: { backgroundColor: "transparent" },
64 headerShown: true,
65 headerTransparent: true,
66 headerShadowVisible: false,
67 }}
68 />
69 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
70 <Stack.Screen name="new" options={newTaskScreenOptions} />
71 <Stack.Screen
72 name="threads/[environmentId]/[threadId]"
73 options={{
74 animation: "slide_from_right",
75 contentStyle: { backgroundColor: "transparent" },
76 gestureEnabled: true,
77 headerShown: false,
78 }}
79 />
80 </Stack>
81 </>
82 );
83}
84
85export default function RootLayout() {
86 const [fontsLoaded] = useFonts({
87 DMSans_400Regular,
88 DMSans_500Medium,
89 DMSans_700Bold,
90 });
91 useRemoteEnvironmentBootstrap();
92
93 return (
94 <RegistryContext value={appAtomRegistry}>
95 <GestureHandlerRootView style={{ flex: 1 }}>
96 <KeyboardProvider statusBarTranslucent>
97 <SafeAreaProvider>
98 {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />}
99 </SafeAreaProvider>
100 </KeyboardProvider>
101 </GestureHandlerRootView>
102 </RegistryContext>
103 );
104}
apps/mobile/src/app/connections/_layout.tsx created+30
...@@ -0,0 +1,30 @@
1import Stack from "expo-router/stack";
2import { useResolveClassNames } from "uniwind";
3
4import { useThemeColor } from "../../lib/useThemeColor";
5
6export const unstable_settings = {
7 anchor: "index",
8};
9
10export default function ConnectionsLayout() {
11 const contentStyle = useResolveClassNames("bg-sheet");
12 const connSheetBg = String(useThemeColor("--color-sheet"));
13 const headerTint = String(useThemeColor("--color-icon"));
14
15 return (
16 <Stack
17 screenOptions={{
18 contentStyle,
19 headerStyle: { backgroundColor: connSheetBg },
20 headerTintColor: headerTint,
21 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
22 headerShadowVisible: false,
23 headerBackButtonDisplayMode: "minimal",
24 }}
25 >
26 <Stack.Screen name="index" options={{ animation: "none" }} />
27 <Stack.Screen name="new" options={{ animation: "slide_from_right" }} />
28 </Stack>
29 );
30}
apps/mobile/src/app/connections/index.tsx created+103
...@@ -0,0 +1,103 @@
1import { Link, Stack } from "expo-router";
2import { SymbolView } from "expo-symbols";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { useCallback, useState } from "react";
5import { Pressable, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
8
9import { AppText as Text } from "../../components/AppText";
10import { cn } from "../../lib/cn";
11import { useRemoteConnections } from "../../state/use-remote-environment-registry";
12import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
13
14export default function ConnectionsRouteScreen() {
15 const {
16 connectedEnvironments,
17 onReconnectEnvironment,
18 onRemoveEnvironmentPress,
19 onUpdateEnvironment,
20 } = useRemoteConnections();
21 const insets = useSafeAreaInsets();
22 const hasEnvironments = connectedEnvironments.length > 0;
23 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
24
25 const primaryFg = useThemeColor("--color-primary-foreground");
26 const accentColor = useThemeColor("--color-icon-muted");
27
28 const handleToggle = useCallback((environmentId: EnvironmentId) => {
29 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
30 }, []);
31
32 return (
33 <View collapsable={false} className="flex-1 bg-sheet">
34 <Stack.Screen
35 options={{
36 title: "Environments",
37 headerRight: () => (
38 <Link href="/connections/new" asChild>
39 <Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary active:opacity-70">
40 <SymbolView
41 name="plus"
42 size={18}
43 tintColor={primaryFg}
44 type="monochrome"
45 weight="semibold"
46 />
47 </Pressable>
48 </Link>
49 ),
50 }}
51 />
52 <ScrollView
53 contentInsetAdjustmentBehavior="automatic"
54 showsVerticalScrollIndicator={false}
55 style={{ flex: 1 }}
56 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
57 contentContainerStyle={{
58 paddingHorizontal: 20,
59 paddingTop: 16,
60 }}
61 >
62 {hasEnvironments ? (
63 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
64 {connectedEnvironments.map((environment, index) => (
65 <View
66 key={environment.environmentId}
67 collapsable={false}
68 style={{
69 borderTopWidth: index === 0 ? 0 : 1,
70 }}
71 className={cn(index !== 0 && "border-border")}
72 >
73 <ConnectionEnvironmentRow
74 environment={environment}
75 expanded={expandedId === environment.environmentId}
76 onToggle={() => handleToggle(environment.environmentId)}
77 onReconnect={onReconnectEnvironment}
78 onRemove={onRemoveEnvironmentPress}
79 onUpdate={onUpdateEnvironment}
80 />
81 </View>
82 ))}
83 </View>
84 ) : (
85 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
86 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
87 <SymbolView
88 name="point.3.connected.trianglepath.dotted"
89 size={20}
90 tintColor={accentColor}
91 type="monochrome"
92 />
93 </View>
94 <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
95 No environments connected yet.{"\n"}Tap{" "}
96 <Text className="font-t3-bold text-foreground">+</Text> to add one.
97 </Text>
98 </View>
99 )}
100 </ScrollView>
101 </View>
102 );
103}
apps/mobile/src/app/connections/new.tsx created+252
...@@ -0,0 +1,252 @@
1import { CameraView, useCameraPermissions } from "expo-camera";
2import { Stack, useLocalSearchParams, useRouter } from "expo-router";
3import { SymbolView } from "expo-symbols";
4import { useCallback, useEffect, useState } from "react";
5import { Alert, Pressable, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
8
9import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
10import { ErrorBanner } from "../../components/ErrorBanner";
11import { dismissRoute } from "../../lib/routes";
12import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton";
13import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing";
14import { useRemoteConnections } from "../../state/use-remote-environment-registry";
15import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing";
16
17export default function ConnectionsNewRouteScreen() {
18 const {
19 connectionPairingUrl,
20 onChangeConnectionPairingUrl,
21 onConnectPress,
22 pairingConnectionError,
23 } = useRemoteConnections();
24 const router = useRouter();
25 const params = useLocalSearchParams<{ mode?: string }>();
26 const insets = useSafeAreaInsets();
27 const [hostInput, setHostInput] = useState("");
28 const [codeInput, setCodeInput] = useState("");
29 const [isSubmitting, setIsSubmitting] = useState(false);
30 const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
31 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
32 const [scannerLocked, setScannerLocked] = useState(false);
33
34 const textColor = useThemeColor("--color-icon");
35 const placeholderColor = useThemeColor("--color-placeholder");
36
37 const connectDisabled = isSubmitting || hostInput.trim().length === 0;
38
39 useEffect(() => {
40 const { host, code } = parsePairingUrl(connectionPairingUrl);
41 setHostInput(host);
42 setCodeInput(code);
43 }, [connectionPairingUrl]);
44
45 useEffect(() => {
46 if (pairingConnectionError) {
47 setIsSubmitting(false);
48 }
49 }, [pairingConnectionError]);
50
51 const handleHostChange = useCallback((value: string) => {
52 setHostInput(value);
53 }, []);
54
55 const handleCodeChange = useCallback((value: string) => {
56 setCodeInput(value);
57 }, []);
58
59 const openScanner = useCallback(async () => {
60 if (cameraPermission?.granted) {
61 setScannerLocked(false);
62 setShowScanner(true);
63 return;
64 }
65
66 const permission = await requestCameraPermission();
67 if (permission.granted) {
68 setScannerLocked(false);
69 setShowScanner(true);
70 return;
71 }
72
73 Alert.alert(
74 "Camera access needed",
75 "Allow camera access to scan an environment pairing QR code.",
76 );
77 }, [cameraPermission?.granted, requestCameraPermission]);
78
79 const closeScanner = useCallback(() => {
80 setShowScanner(false);
81 setScannerLocked(false);
82 }, []);
83
84 const handleQrScan = useCallback(
85 ({ data }: { readonly data: string }) => {
86 if (scannerLocked) {
87 return;
88 }
89
90 setScannerLocked(true);
91
92 try {
93 const pairingUrl = extractPairingUrlFromQrPayload(data);
94 const { host, code } = parsePairingUrl(pairingUrl);
95 setHostInput(host);
96 setCodeInput(code);
97 onChangeConnectionPairingUrl(pairingUrl);
98 setShowScanner(false);
99 } catch (error) {
100 Alert.alert(
101 "Invalid QR code",
102 error instanceof Error ? error.message : "Scanned QR code was not recognized.",
103 );
104 } finally {
105 setTimeout(() => {
106 setScannerLocked(false);
107 }, 600);
108 }
109 },
110 [onChangeConnectionPairingUrl, scannerLocked],
111 );
112
113 const handleSubmit = useCallback(async () => {
114 setIsSubmitting(true);
115
116 try {
117 const pairingUrl = buildPairingUrl(hostInput, codeInput);
118 onChangeConnectionPairingUrl(pairingUrl);
119 await onConnectPress(pairingUrl);
120 dismissRoute(router);
121 } catch {
122 setIsSubmitting(false);
123 }
124 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
125
126 return (
127 <View collapsable={false} className="flex-1 bg-sheet">
128 <Stack.Screen
129 options={{
130 title: showScanner ? "Scan QR Code" : "Add Environment",
131 headerRight: () => (
132 <Pressable
133 className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary"
134 onPress={() => {
135 if (showScanner) {
136 closeScanner();
137 } else {
138 void openScanner();
139 }
140 }}
141 >
142 <SymbolView
143 name={showScanner ? "xmark" : "qrcode.viewfinder"}
144 size={showScanner ? 14 : 18}
145 tintColor={textColor}
146 type="monochrome"
147 weight="semibold"
148 />
149 </Pressable>
150 ),
151 }}
152 />
153
154 <ScrollView
155 contentInsetAdjustmentBehavior="automatic"
156 showsVerticalScrollIndicator={false}
157 style={{ flex: 1 }}
158 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
159 contentContainerStyle={{
160 paddingHorizontal: 20,
161 paddingTop: 16,
162 }}
163 >
164 <View collapsable={false} className="gap-5">
165 {showScanner ? (
166 cameraPermission?.granted ? (
167 <View
168 className="overflow-hidden rounded-[24px]"
169 style={{ borderCurve: "continuous" }}
170 >
171 <CameraView
172 barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
173 onBarcodeScanned={handleQrScan}
174 style={{ aspectRatio: 1, width: "100%" }}
175 />
176 </View>
177 ) : (
178 <View
179 className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
180 style={{ borderCurve: "continuous" }}
181 >
182 <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
183 Camera permission is required to scan a QR code.
184 </Text>
185 <ConnectionSheetButton
186 compact
187 icon="camera"
188 label="Allow camera"
189 tone="secondary"
190 onPress={() => {
191 void openScanner();
192 }}
193 />
194 </View>
195 )
196 ) : (
197 <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
198 <View collapsable={false} className="gap-1.5">
199 <Text
200 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
201 style={{ letterSpacing: 0.8 }}
202 >
203 Host
204 </Text>
205 <TextInput
206 autoCapitalize="none"
207 autoCorrect={false}
208 keyboardType="url"
209 placeholder="192.168.1.100:8080"
210 placeholderTextColor={placeholderColor}
211 value={hostInput}
212 onChangeText={handleHostChange}
213 className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
214 />
215 </View>
216
217 <View collapsable={false} className="gap-1.5">
218 <Text
219 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
220 style={{ letterSpacing: 0.8 }}
221 >
222 Pairing code
223 </Text>
224 <TextInput
225 autoCapitalize="none"
226 autoCorrect={false}
227 placeholder="abc-123-xyz"
228 placeholderTextColor={placeholderColor}
229 value={codeInput}
230 onChangeText={handleCodeChange}
231 className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
232 />
233 </View>
234
235 {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}
236
237 <ConnectionSheetButton
238 icon="plus"
239 label={isSubmitting ? "Pairing..." : "Add environment"}
240 disabled={connectDisabled}
241 tone="primary"
242 onPress={() => {
243 void handleSubmit();
244 }}
245 />
246 </View>
247 )}
248 </View>
249 </ScrollView>
250 </View>
251 );
252}
apps/mobile/src/app/index.tsx created+117
...@@ -0,0 +1,117 @@
1import { Stack, useRouter } from "expo-router";
2import { useState } from "react";
3import { Text as RNText, View, useColorScheme } from "react-native";
4import { useThemeColor } from "../lib/useThemeColor";
5
6import { buildThreadRoutePath } from "../lib/routes";
7import { useRemoteCatalog } from "../state/use-remote-catalog";
8import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry";
9import { HomeScreen } from "../features/home/HomeScreen";
10
11/* ─── Route screen ───────────────────────────────────────────────────── */
12
13export default function HomeRouteScreen() {
14 const { projects, state: catalogState, threads } = useRemoteCatalog();
15 const { savedConnectionsById } = useRemoteEnvironmentState();
16 const router = useRouter();
17 const [searchQuery, setSearchQuery] = useState("");
18
19 const isDark = useColorScheme() === "dark";
20 const iconColor = String(useThemeColor("--color-icon"));
21
22 return (
23 <>
24 <Stack.Screen
25 options={{
26 headerShown: true,
27 headerTransparent: true,
28 headerStyle: { backgroundColor: "transparent" },
29 headerShadowVisible: false,
30 headerTintColor: iconColor,
31 headerTitle: "",
32 headerSearchBarOptions: {
33 placeholder: "Search threads",
34 onChangeText: (event) => {
35 setSearchQuery(event.nativeEvent.text);
36 },
37 allowToolbarIntegration: true,
38 },
39 }}
40 />
41
42 {/* Header left: plain text, no Liquid Glass button chrome */}
43 <Stack.Toolbar placement="left">
44 <Stack.Toolbar.View hidesSharedBackground>
45 <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
46 <RNText
47 style={{
48 fontFamily: "DMSans_700Bold",
49 fontSize: 17,
50 color: iconColor,
51 letterSpacing: -0.4,
52 }}
53 >
54 T3 Code
55 </RNText>
56 <View
57 style={{
58 backgroundColor: isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)",
59 borderRadius: 99,
60 paddingHorizontal: 8,
61 paddingVertical: 3,
62 }}
63 >
64 <RNText
65 style={{
66 fontFamily: "DMSans_700Bold",
67 fontSize: 10,
68 color: "#737373",
69 letterSpacing: 1.1,
70 textTransform: "uppercase",
71 }}
72 >
73 Alpha
74 </RNText>
75 </View>
76 </View>
77 </Stack.Toolbar.View>
78 </Stack.Toolbar>
79
80 <Stack.Toolbar placement="right">
81 <Stack.Toolbar.Menu icon="ellipsis.circle" separateBackground>
82 <Stack.Toolbar.Label>More</Stack.Toolbar.Label>
83 <Stack.Toolbar.MenuAction
84 icon="desktopcomputer"
85 onPress={() => router.push("/connections")}
86 subtitle="Manage connected hosts"
87 >
88 <Stack.Toolbar.Label>Environments</Stack.Toolbar.Label>
89 </Stack.Toolbar.MenuAction>
90 </Stack.Toolbar.Menu>
91 </Stack.Toolbar>
92
93 {/* Bottom toolbar: search + compose, visually split like iMessage */}
94 <Stack.Toolbar placement="bottom">
95 <Stack.Toolbar.SearchBarSlot />
96 <Stack.Toolbar.Spacer width={8} sharesBackground={false} />
97 <Stack.Toolbar.Button
98 icon="square.and.pencil"
99 onPress={() => router.push("/new")}
100 separateBackground
101 />
102 </Stack.Toolbar>
103
104 <HomeScreen
105 projects={projects}
106 threads={threads}
107 catalogState={catalogState}
108 savedConnectionsById={savedConnectionsById}
109 searchQuery={searchQuery}
110 onAddConnection={() => router.push("/connections/new")}
111 onSelectThread={(thread) => {
112 router.push(buildThreadRoutePath(thread));
113 }}
114 />
115 </>
116 );
117}
apps/mobile/src/app/new/_layout.tsx created+29
...@@ -0,0 +1,29 @@
1import Stack from "expo-router/stack";
2import { useResolveClassNames } from "uniwind";
3
4import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
5
6export const unstable_settings = {
7 anchor: "index",
8};
9
10export default function NewTaskLayout() {
11 const sheetStyle = useResolveClassNames("bg-sheet");
12
13 return (
14 <NewTaskFlowProvider>
15 <Stack
16 screenOptions={{
17 contentStyle: sheetStyle,
18 }}
19 >
20 <Stack.Screen name="index" options={{ animation: "none" }} />
21 <Stack.Screen
22 name="add-project"
23 options={{ animation: "slide_from_right", headerShown: false }}
24 />
25 <Stack.Screen name="draft" options={{ animation: "slide_from_right" }} />
26 </Stack>
27 </NewTaskFlowProvider>
28 );
29}
apps/mobile/src/app/new/add-project/_layout.tsx created+17
...@@ -0,0 +1,17 @@
1import { Stack } from "expo-router";
2import { useThemeColor } from "../../../lib/useThemeColor";
3
4export default function AddProjectLayout() {
5 const sheetStyle = {
6 backgroundColor: useThemeColor("--color-sheet"),
7 };
8
9 return (
10 <Stack screenOptions={{ headerShown: false, contentStyle: sheetStyle }}>
11 <Stack.Screen name="index" options={{ animation: "slide_from_right" }} />
12 <Stack.Screen name="repository" options={{ animation: "slide_from_right" }} />
13 <Stack.Screen name="destination" options={{ animation: "slide_from_right" }} />
14 <Stack.Screen name="local" options={{ animation: "slide_from_right" }} />
15 </Stack>
16 );
17}
apps/mobile/src/app/new/add-project/destination.tsx created+12
...@@ -0,0 +1,12 @@
1import { Stack } from "expo-router";
2
3import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen";
4
5export default function AddProjectDestinationRoute() {
6 return (
7 <>
8 <Stack.Screen options={{ headerShown: false }} />
9 <AddProjectDestinationScreen />
10 </>
11 );
12}
apps/mobile/src/app/new/add-project/index.tsx created+12
...@@ -0,0 +1,12 @@
1import { Stack } from "expo-router";
2
3import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen";
4
5export default function AddProjectRoute() {
6 return (
7 <>
8 <Stack.Screen options={{ headerShown: false }} />
9 <AddProjectSourceScreen />
10 </>
11 );
12}
apps/mobile/src/app/new/add-project/local.tsx created+12
...@@ -0,0 +1,12 @@
1import { Stack } from "expo-router";
2
3import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen";
4
5export default function AddProjectLocalRoute() {
6 return (
7 <>
8 <Stack.Screen options={{ headerShown: false }} />
9 <AddProjectLocalFolderScreen />
10 </>
11 );
12}
apps/mobile/src/app/new/add-project/repository.tsx created+12
...@@ -0,0 +1,12 @@
1import { Stack } from "expo-router";
2
3import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";
4
5export default function AddProjectRepositoryRoute() {
6 return (
7 <>
8 <Stack.Screen options={{ headerShown: false }} />
9 <AddProjectRepositoryScreen />
10 </>
11 );
12}
apps/mobile/src/app/new/draft.tsx created+24
...@@ -0,0 +1,24 @@
1import { Stack, useLocalSearchParams } from "expo-router";
2
3import { NewTaskDraftScreen } from "../../features/threads/NewTaskDraftScreen";
4
5export default function NewTaskDraftRoute() {
6 const params = useLocalSearchParams<{
7 environmentId?: string | string[];
8 projectId?: string | string[];
9 }>();
10
11 return (
12 <>
13 <Stack.Screen options={{ headerShown: false }} />
14 <NewTaskDraftScreen
15 initialProjectRef={{
16 environmentId: Array.isArray(params.environmentId)
17 ? params.environmentId[0]
18 : params.environmentId,
19 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
20 }}
21 />
22 </>
23 );
24}
apps/mobile/src/app/new/index.tsx created+232
...@@ -0,0 +1,232 @@
1import { Link, Stack, useRouter } from "expo-router";
2import { SymbolView } from "expo-symbols";
3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
4import { useMemo } from "react";
5import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
8
9import { AppText as Text } from "../../components/AppText";
10import { ProjectFavicon } from "../../components/ProjectFavicon";
11import { NewTaskSheetHeader } from "../../features/threads/NewTaskSheetHeader";
12import { groupProjectsByRepository } from "../../lib/repositoryGroups";
13import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog";
14import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
15
16function deriveProjectEmptyState(catalogState: RemoteCatalogState): {
17 readonly title: string;
18 readonly detail: string;
19 readonly loading: boolean;
20} {
21 if (catalogState.isLoadingSavedConnections) {
22 return {
23 title: "Loading environments",
24 detail: "Checking saved environments on this device.",
25 loading: true,
26 };
27 }
28
29 if (!catalogState.hasSavedConnections) {
30 return {
31 title: "No environments connected",
32 detail: "Add an environment before creating a task.",
33 loading: false,
34 };
35 }
36
37 if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) {
38 return {
39 title: "Environment unavailable",
40 detail:
41 catalogState.connectionError ??
42 "The saved environment is offline. Check the URL or start the environment, then retry.",
43 loading: false,
44 };
45 }
46
47 if (
48 catalogState.hasConnectingEnvironment &&
49 !catalogState.hasLoadedShellSnapshot &&
50 catalogState.connectionError === null
51 ) {
52 return {
53 title: "Connecting to environment",
54 detail: "Loading projects from the saved environment.",
55 loading: true,
56 };
57 }
58
59 return {
60 title: "No projects found",
61 detail: "The connected environment did not report any projects.",
62 loading: false,
63 };
64}
65
66export default function NewTaskRoute() {
67 const { projects, state: catalogState, threads } = useRemoteCatalog();
68 const { savedConnectionsById } = useRemoteEnvironmentState();
69 const router = useRouter();
70 const insets = useSafeAreaInsets();
71 const chevronColor = useThemeColor("--color-chevron");
72 const accentColor = useThemeColor("--color-icon-muted");
73 const borderSubtleColor = useThemeColor("--color-border-subtle");
74 const repositoryGroups = useMemo(
75 () => groupProjectsByRepository({ projects, threads }),
76 [projects, threads],
77 );
78 const items = useMemo(() => {
79 const nextItems: Array<{
80 readonly environmentId: EnvironmentId;
81 readonly id: ProjectId;
82 readonly key: string;
83 readonly title: string;
84 readonly workspaceRoot: string;
85 }> = [];
86 for (const group of repositoryGroups) {
87 const project = group.projects[0]?.project;
88 if (!project) {
89 continue;
90 }
91 nextItems.push({
92 environmentId: project.environmentId,
93 id: project.id,
94 key: group.key,
95 title: project.title,
96 workspaceRoot: project.workspaceRoot,
97 });
98 }
99 return nextItems;
100 }, [repositoryGroups]);
101 const projectEmptyState = deriveProjectEmptyState(catalogState);
102
103 return (
104 <View collapsable={false} className="flex-1 bg-sheet">
105 <Stack.Screen options={{ headerShown: false }} />
106
107 <NewTaskSheetHeader title="Choose project" />
108
109 <ScrollView
110 showsVerticalScrollIndicator={false}
111 style={{ flex: 1 }}
112 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
113 contentContainerStyle={{
114 paddingHorizontal: 20,
115 paddingTop: 8,
116 }}
117 >
118 {items.length === 0 ? (
119 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
120 {projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null}
121 <Text className="text-center text-[17px] font-t3-bold text-foreground">
122 {projectEmptyState.title}
123 </Text>
124 <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
125 {projectEmptyState.detail}
126 </Text>
127 {!catalogState.hasReadyEnvironment ? (
128 <Pressable
129 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
130 onPress={() => router.push("/connections/new")}
131 >
132 <Text className="text-[13px] font-t3-bold text-primary-foreground">
133 Add environment
134 </Text>
135 </Pressable>
136 ) : (
137 <Pressable
138 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
139 onPress={() => router.push("/new/add-project")}
140 >
141 <Text className="text-[13px] font-t3-bold text-primary-foreground">
142 Add new project
143 </Text>
144 </Pressable>
145 )}
146 </View>
147 ) : (
148 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
149 {items.map((item, index) => {
150 const isFirst = index === 0;
151
152 return (
153 <Link
154 key={item.key}
155 href={{
156 pathname: "/new/draft",
157 params: {
158 environmentId: item.environmentId,
159 projectId: item.id,
160 title: item.title,
161 },
162 }}
163 asChild
164 >
165 <Pressable
166 className="bg-card"
167 style={{
168 paddingHorizontal: 16,
169 paddingVertical: 18,
170 borderTopWidth: isFirst ? 0 : 1,
171 borderTopColor: borderSubtleColor,
172 borderTopLeftRadius: isFirst ? 24 : 0,
173 borderTopRightRadius: isFirst ? 24 : 0,
174 borderBottomLeftRadius: 0,
175 borderBottomRightRadius: 0,
176 }}
177 >
178 <View className="flex-row items-center justify-between gap-3">
179 <ProjectFavicon
180 size={22}
181 projectTitle={item.title}
182 httpBaseUrl={savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null}
183 workspaceRoot={item.workspaceRoot}
184 bearerToken={savedConnectionsById[item.environmentId]?.bearerToken ?? null}
185 />
186 <View className="flex-1">
187 <Text className="text-[18px] font-t3-bold">{item.title}</Text>
188 </View>
189 <SymbolView
190 name="chevron.right"
191 size={14}
192 tintColor={chevronColor}
193 type="monochrome"
194 />
195 </View>
196 </Pressable>
197 </Link>
198 );
199 })}
200 <Pressable
201 className="bg-card"
202 style={{
203 paddingHorizontal: 16,
204 paddingVertical: 18,
205 borderTopWidth: 1,
206 borderTopColor: borderSubtleColor,
207 borderBottomLeftRadius: 24,
208 borderBottomRightRadius: 24,
209 }}
210 onPress={() => router.push("/new/add-project")}
211 >
212 <View className="flex-row items-center justify-between gap-3">
213 <View className="h-[22px] w-[22px] items-center justify-center rounded-full bg-subtle">
214 <SymbolView name="plus" size={13} tintColor={accentColor} type="monochrome" />
215 </View>
216 <View className="flex-1">
217 <Text className="text-[18px] font-t3-bold">Add new project</Text>
218 </View>
219 <SymbolView
220 name="chevron.right"
221 size={14}
222 tintColor={chevronColor}
223 type="monochrome"
224 />
225 </View>
226 </Pressable>
227 </View>
228 )}
229 </ScrollView>
230 </View>
231 );
232}
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx created+80
...@@ -0,0 +1,80 @@
1import Stack from "expo-router/stack";
2import { StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export default function ThreadLayout() {
6 const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
7 const headerBg = {
8 backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
9 };
10
11 return (
12 <Stack screenOptions={{ headerShown: false }}>
13 <Stack.Screen
14 name="index"
15 options={{
16 contentStyle: { backgroundColor: "transparent" },
17 headerShown: true,
18 headerTransparent: true,
19 headerShadowVisible: false,
20 }}
21 />
22 <Stack.Screen
23 name="git"
24 options={{
25 contentStyle: sheetStyle,
26 gestureEnabled: true,
27 headerShown: false,
28 presentation: "formSheet" as const,
29 sheetAllowedDetents: [0.85],
30 sheetGrabberVisible: true,
31 }}
32 />
33 <Stack.Screen
34 name="git-confirm"
35 options={{
36 contentStyle: sheetStyle,
37 gestureEnabled: true,
38 headerShown: false,
39 presentation: "formSheet" as const,
40 sheetAllowedDetents: [0.4],
41 sheetGrabberVisible: true,
42 }}
43 />
44 <Stack.Screen
45 name="review"
46 options={{
47 animation: "slide_from_right",
48 contentStyle: sheetStyle,
49 headerBackButtonDisplayMode: "minimal",
50 headerShown: true,
51 headerTitle: "Files changed",
52 headerBackTitle: "",
53 headerShadowVisible: false,
54 headerStyle: headerBg,
55 }}
56 />
57 <Stack.Screen
58 name="review-comment"
59 options={{
60 contentStyle: sheetStyle,
61 gestureEnabled: true,
62 headerShown: false,
63 presentation: "formSheet" as const,
64 sheetAllowedDetents: [0.72, 0.92],
65 sheetGrabberVisible: true,
66 }}
67 />
68 <Stack.Screen
69 name="terminal"
70 options={{
71 animation: "slide_from_right",
72 contentStyle: { backgroundColor: "#050505" },
73 headerBackButtonDisplayMode: "minimal",
74 headerShown: true,
75 headerShadowVisible: false,
76 }}
77 />
78 </Stack>
79 );
80}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx created+5
...@@ -0,0 +1,5 @@
1import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet";
2
3export default function GitConfirmRoute() {
4 return <GitConfirmSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx created+58
...@@ -0,0 +1,58 @@
1import Stack from "expo-router/stack";
2import { StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export const unstable_settings = {
6 anchor: "index",
7};
8
9export default function GitSheetLayout() {
10 const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
11 const headerBg = {
12 backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
13 };
14
15 return (
16 <Stack
17 screenOptions={{
18 contentStyle: sheetStyle,
19 headerShown: false,
20 }}
21 >
22 <Stack.Screen name="index" options={{ animation: "none" }} />
23 <Stack.Screen
24 name="commit"
25 options={{
26 animation: "slide_from_right",
27 headerShown: true,
28 headerTitle: "Commit changes",
29 headerBackTitle: "",
30 headerShadowVisible: false,
31 headerStyle: headerBg,
32 }}
33 />
34 <Stack.Screen
35 name="branches"
36 options={{
37 animation: "slide_from_right",
38 headerShown: true,
39 headerTitle: "Branches & worktrees",
40 headerBackTitle: "",
41 headerShadowVisible: false,
42 headerStyle: headerBg,
43 }}
44 />
45 <Stack.Screen
46 name="review"
47 options={{
48 animation: "slide_from_right",
49 headerShown: true,
50 headerTitle: "Review changes",
51 headerBackTitle: "",
52 headerShadowVisible: false,
53 headerStyle: headerBg,
54 }}
55 />
56 </Stack>
57 );
58}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx created+5
...@@ -0,0 +1,5 @@
1import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet";
2
3export default function GitBranchesRoute() {
4 return <GitBranchesSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx created+5
...@@ -0,0 +1,5 @@
1import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet";
2
3export default function GitCommitRoute() {
4 return <GitCommitSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx created+5
...@@ -0,0 +1,5 @@
1import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet";
2
3export default function GitRoute() {
4 return <GitOverviewSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx created+18
...@@ -0,0 +1,18 @@
1import { Redirect, useLocalSearchParams } from "expo-router";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3
4export default function ReviewRoute() {
5 const { environmentId, threadId } = useLocalSearchParams<{
6 environmentId: EnvironmentId;
7 threadId: ThreadId;
8 }>();
9
10 return (
11 <Redirect
12 href={{
13 pathname: "/threads/[environmentId]/[threadId]/review",
14 params: { environmentId, threadId },
15 }}
16 />
17 );
18}
apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx created+5
...@@ -0,0 +1,5 @@
1import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen";
2
3export default function ThreadRoute() {
4 return <ThreadRouteScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx created+5
...@@ -0,0 +1,5 @@
1import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet";
2
3export default function ReviewCommentRoute() {
4 return <ReviewCommentComposerSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx created+10
...@@ -0,0 +1,10 @@
1import { ReviewHighlighterProvider } from "../../../../features/review/ReviewHighlighterProvider";
2import { ReviewSheet } from "../../../../features/review/ReviewSheet";
3
4export default function ReviewRoute() {
5 return (
6 <ReviewHighlighterProvider>
7 <ReviewSheet />
8 </ReviewHighlighterProvider>
9 );
10}
apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx created+5
...@@ -0,0 +1,5 @@
1import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen";
2
3export default function ThreadTerminalRoute() {
4 return <ThreadTerminalRouteScreen />;
5}
apps/mobile/src/components/AppText.tsx created+50
...@@ -0,0 +1,50 @@
1import type { Ref } from "react";
2import {
3 Text as RNText,
4 TextInput as RNTextInput,
5 type TextInputProps as RNTextInputProps,
6 type TextProps as RNTextProps,
7} from "react-native";
8import { useThemeColor } from "../lib/useThemeColor";
9
10import { cn } from "../lib/cn";
11
12export type AppTextProps = RNTextProps & { readonly className?: string };
13
14/**
15 * Thin wrapper around RN Text with default font-family and foreground color.
16 * Uses Uniwind className — no manual style parsing.
17 */
18export function AppText({ className, ...props }: AppTextProps) {
19 return <RNText className={cn("font-sans text-foreground", className)} {...props} />;
20}
21
22export type AppTextInputProps = RNTextInputProps & {
23 readonly className?: string;
24 readonly ref?: Ref<RNTextInput>;
25};
26
27/**
28 * Thin wrapper around RN TextInput with default input styling.
29 * Uses Uniwind className — no manual style parsing.
30 */
31export function AppTextInput({
32 className,
33 placeholderTextColor,
34 ref,
35 ...props
36}: AppTextInputProps) {
37 const placeholderColor = useThemeColor("--color-placeholder");
38
39 return (
40 <RNTextInput
41 ref={ref}
42 className={cn(
43 "min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-[15px] text-foreground",
44 className,
45 )}
46 placeholderTextColor={placeholderTextColor ?? placeholderColor}
47 {...props}
48 />
49 );
50}
apps/mobile/src/components/BrandMark.tsx created+48
...@@ -0,0 +1,48 @@
1import { Image, View } from "react-native";
2
3import { AppText as Text } from "./AppText";
4
5const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");
6
7export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) {
8 const compact = props.compact ?? false;
9 const iconSize = compact ? 32 : 44;
10 const stageLabel = props.stageLabel ?? "Alpha";
11
12 return (
13 <View className="flex-row items-center gap-3">
14 <Image
15 source={BRAND_MARK_SOURCE}
16 accessibilityIgnoresInvertColors
17 style={{
18 width: iconSize,
19 height: iconSize,
20 borderRadius: compact ? 10 : 14,
21 }}
22 />
23 <View className="gap-1">
24 <View className="flex-row items-center gap-2">
25 <Text
26 className="text-[17px] font-t3-bold text-foreground"
27 style={{ letterSpacing: -0.4 }}
28 >
29 T3 Code
30 </Text>
31 <View className="rounded-full bg-subtle px-2 py-1">
32 <Text
33 className="text-[10px] font-t3-bold uppercase text-foreground-muted"
34 style={{ letterSpacing: 1.1 }}
35 >
36 {stageLabel}
37 </Text>
38 </View>
39 </View>
40 {!compact ? (
41 <Text className="text-[12px] font-medium text-foreground-muted">
42 Mobile control surface for your live coding environments
43 </Text>
44 ) : null}
45 </View>
46 </View>
47 );
48}
apps/mobile/src/components/ComposerAttachmentStrip.tsx created+96
...@@ -0,0 +1,96 @@
1import { SymbolView } from "expo-symbols";
2import { Image, Pressable, ScrollView, View } from "react-native";
3import { useThemeColor } from "../lib/useThemeColor";
4
5import type { DraftComposerImageAttachment } from "../lib/composerImages";
6
7export interface ComposerAttachmentStripProps {
8 /** Attachment images to display. */
9 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
10 /** Called when the user taps the remove button on an image. */
11 readonly onRemove: (imageId: string) => void;
12 /** Called when the user taps on an image thumbnail to preview it. */
13 readonly onPressImage?: (previewUri: string) => void;
14 /** Image thumbnail size in points. Defaults to 72. */
15 readonly imageSize?: number;
16 /** Border radius of each image thumbnail. Defaults to 16. */
17 readonly imageBorderRadius?: number;
18 /** Whether the remove button should sit in its own gutter instead of overlapping the image. */
19 readonly removeButtonPlacement?: "overlay" | "gutter";
20}
21
22/**
23 * A horizontally-scrollable strip of image attachment thumbnails with remove
24 * buttons. Used by both the thread composer and the new-task draft screen.
25 */
26export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
27 const subtleBg = useThemeColor("--color-subtle");
28 const size = props.imageSize ?? 72;
29 const radius = props.imageBorderRadius ?? 16;
30 const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";
31 const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0;
32
33 if (props.attachments.length === 0) {
34 return null;
35 }
36
37 return (
38 <ScrollView
39 horizontal
40 showsHorizontalScrollIndicator={false}
41 keyboardShouldPersistTaps="always"
42 style={{ flexGrow: 0 }}
43 >
44 <View style={{ flexDirection: "row", gap: 10 }}>
45 {props.attachments.map((image) => (
46 <View
47 key={image.id}
48 style={{
49 position: "relative",
50 paddingTop: removeButtonGutter,
51 paddingRight: removeButtonGutter,
52 }}
53 >
54 <Pressable
55 onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined}
56 >
57 <Image
58 source={{ uri: image.previewUri }}
59 style={{
60 width: size,
61 height: size,
62 borderRadius: radius,
63 backgroundColor: subtleBg,
64 }}
65 resizeMode="cover"
66 />
67 </Pressable>
68 <Pressable
69 style={{
70 position: "absolute",
71 top: removeButtonPlacement === "gutter" ? 0 : 4,
72 right: removeButtonPlacement === "gutter" ? 0 : 4,
73 width: 22,
74 height: 22,
75 borderRadius: 11,
76 backgroundColor: "rgba(0,0,0,0.55)",
77 alignItems: "center",
78 justifyContent: "center",
79 }}
80 hitSlop={6}
81 onPress={() => props.onRemove(image.id)}
82 >
83 <SymbolView
84 name="xmark"
85 size={9}
86 tintColor="#ffffff"
87 type="monochrome"
88 weight="bold"
89 />
90 </Pressable>
91 </View>
92 ))}
93 </View>
94 </ScrollView>
95 );
96}
apps/mobile/src/components/ControlPill.tsx created+89
...@@ -0,0 +1,89 @@
1import { MenuView } from "@react-native-menu/menu";
2import type { ComponentProps, ReactNode } from "react";
3import { Pressable, useColorScheme, View } from "react-native";
4import { SymbolView } from "expo-symbols";
5import { useThemeColor } from "../lib/useThemeColor";
6
7import { cn } from "../lib/cn";
8import { AppText as Text } from "./AppText";
9
10export function ControlPill(props: {
11 readonly icon?: ComponentProps<typeof SymbolView>["name"];
12 readonly iconNode?: ReactNode;
13 readonly label?: string;
14 readonly accessibilityLabel?: string;
15 readonly onPress?: () => void;
16 readonly variant?: "circle" | "pill" | "primary" | "danger";
17 readonly disabled?: boolean;
18}) {
19 const variant = props.variant ?? "circle";
20
21 const iconColor = useThemeColor("--color-icon");
22 const iconSubtle = useThemeColor("--color-icon-subtle");
23 const primaryFg = useThemeColor("--color-primary-foreground");
24 const dangerFg = useThemeColor("--color-danger-foreground");
25 const iconTintColor =
26 variant === "primary"
27 ? props.disabled
28 ? iconSubtle
29 : primaryFg
30 : variant === "danger"
31 ? dangerFg
32 : iconColor;
33
34 const isCircle =
35 variant === "circle" || variant === "danger" || (variant === "primary" && !props.label);
36 const containerClassName = cn(
37 isCircle
38 ? "h-11 w-11 items-center justify-center rounded-full"
39 : variant === "primary"
40 ? "h-11 flex-row items-center justify-center gap-2 rounded-full px-5"
41 : "h-11 flex-row items-center justify-center gap-2 rounded-full px-3.5",
42 variant === "primary"
43 ? props.disabled
44 ? "bg-subtle-strong"
45 : "bg-primary"
46 : variant === "danger"
47 ? "bg-danger"
48 : "bg-subtle",
49 );
50 const labelClassName = cn(
51 "text-center text-[12px] font-t3-bold",
52 variant === "primary"
53 ? props.disabled
54 ? "text-foreground-muted"
55 : "text-primary-foreground"
56 : "",
57 );
58
59 return (
60 <Pressable
61 accessibilityLabel={props.accessibilityLabel ?? props.label}
62 accessibilityRole="button"
63 onPress={props.onPress}
64 disabled={props.disabled}
65 className={containerClassName}
66 >
67 {props.iconNode ? (
68 <View className="h-4 w-4 items-center justify-center">{props.iconNode}</View>
69 ) : props.icon ? (
70 <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" />
71 ) : null}
72 {props.label ? <Text className={labelClassName}>{props.label}</Text> : null}
73 </Pressable>
74 );
75}
76
77export function ControlPillMenu(
78 props: Omit<ComponentProps<typeof MenuView>, "children" | "style" | "themeVariant"> & {
79 readonly children: ReactNode;
80 },
81) {
82 const isDarkMode = useColorScheme() === "dark";
83
84 return (
85 <MenuView {...props} themeVariant={isDarkMode ? "dark" : "light"}>
86 {props.children}
87 </MenuView>
88 );
89}
apps/mobile/src/components/EmptyState.tsx created+29
...@@ -0,0 +1,29 @@
1import { Pressable, View } from "react-native";
2
3import { AppText as Text } from "./AppText";
4
5export function EmptyState(props: {
6 readonly title: string;
7 readonly detail: string;
8 readonly actionLabel?: string;
9 readonly onAction?: () => void;
10}) {
11 return (
12 <View className="rounded-[22px] border border-border bg-card p-5">
13 <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>
14 <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted">
15 {props.detail}
16 </Text>
17 {props.actionLabel && props.onAction ? (
18 <Pressable
19 className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70"
20 onPress={props.onAction}
21 >
22 <Text className="text-[13px] font-t3-bold text-primary-foreground">
23 {props.actionLabel}
24 </Text>
25 </Pressable>
26 ) : null}
27 </View>
28 );
29}
apps/mobile/src/components/ErrorBanner.tsx created+12
...@@ -0,0 +1,12 @@
1import { View } from "react-native";
2
3import { AppText as Text } from "./AppText";
4export function ErrorBanner(props: { readonly message: string }) {
5 return (
6 <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12">
7 <Text className="font-t3-medium text-[13px] leading-[18px] text-rose-700 dark:text-rose-300">
8 {props.message}
9 </Text>
10 </View>
11 );
12}
apps/mobile/src/components/GlassSafeAreaView.tsx created+59
...@@ -0,0 +1,59 @@
1import type { ReactNode } from "react";
2import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4
5import { GlassSurface } from "./GlassSurface";
6
7export interface GlassSafeAreaViewProps {
8 readonly leftSlot?: ReactNode;
9 readonly centerSlot?: ReactNode;
10 readonly rightSlot?: ReactNode;
11 readonly style?: StyleProp<ViewStyle>;
12}
13
14export function GlassSafeAreaView({
15 leftSlot,
16 centerSlot,
17 rightSlot,
18 style,
19}: GlassSafeAreaViewProps) {
20 const isDarkMode = useColorScheme() === "dark";
21 const insets = useSafeAreaInsets();
22 const headerPaddingTop = insets.top + 16;
23 const surfaceStyle = {
24 borderRadius: 0,
25 backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)",
26 borderBottomWidth: 1,
27 borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)",
28 } as const;
29
30 return (
31 <View style={[surfaceStyle, style]}>
32 <GlassSurface
33 chrome="none"
34 glassEffectStyle="regular"
35 tintColor={isDarkMode ? "rgba(15,23,42,0.24)" : "rgba(255,255,255,0.18)"}
36 style={{ borderRadius: 0, backgroundColor: "transparent" }}
37 >
38 <View
39 style={{
40 flexDirection: "row",
41 alignItems: "center",
42 paddingHorizontal: 20,
43 paddingTop: headerPaddingTop,
44 paddingBottom: 16,
45 gap: 10,
46 }}
47 >
48 <View style={{ alignItems: "flex-start", justifyContent: "center" }}>{leftSlot}</View>
49 <View
50 style={{ flex: 1, alignItems: "center", justifyContent: "center", overflow: "hidden" }}
51 >
52 {centerSlot}
53 </View>
54 <View style={{ alignItems: "flex-end", justifyContent: "center" }}>{rightSlot}</View>
55 </View>
56 </GlassSurface>
57 </View>
58 );
59}
apps/mobile/src/components/GlassSurface.tsx created+73
...@@ -0,0 +1,73 @@
1import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
2import type { ReactNode } from "react";
3import { Platform, useColorScheme, View, type ViewProps, type ViewStyle } from "react-native";
4
5export interface GlassSurfaceProps extends ViewProps {
6 readonly children: ReactNode;
7 readonly glassEffectStyle?: "clear" | "regular" | "none";
8 readonly tintColor?: string;
9 readonly chrome?: "default" | "none";
10}
11
12export function GlassSurface({
13 children,
14 glassEffectStyle = "regular",
15 chrome = "default",
16 tintColor,
17 style,
18 ...props
19}: GlassSurfaceProps) {
20 const isDarkMode = useColorScheme() === "dark";
21 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
22 const surfaceStyle: ViewStyle = {
23 borderRadius: 32,
24 overflow: "hidden",
25 borderWidth: chrome === "none" ? 0 : 1,
26 borderColor:
27 chrome === "none"
28 ? "transparent"
29 : isDarkMode
30 ? "rgba(255,255,255,0.08)"
31 : "rgba(226,232,240,0.9)",
32 backgroundColor:
33 chrome === "none"
34 ? "transparent"
35 : isDarkMode
36 ? "rgba(15,23,42,0.78)"
37 : "rgba(255,255,255,0.72)",
38 shadowColor: chrome === "none" ? "transparent" : "#020617",
39 shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08,
40 shadowRadius: chrome === "none" ? 0 : 28,
41 shadowOffset:
42 chrome === "none"
43 ? {
44 width: 0,
45 height: 0,
46 }
47 : {
48 width: 0,
49 height: 14,
50 },
51 elevation: chrome === "none" ? 0 : 12,
52 };
53
54 if (supportsGlass) {
55 return (
56 <GlassView
57 {...props}
58 glassEffectStyle={glassEffectStyle}
59 tintColor={tintColor ?? (isDarkMode ? "rgba(15,23,42,0.18)" : "rgba(255,255,255,0.18)")}
60 colorScheme={isDarkMode ? "dark" : "light"}
61 style={[surfaceStyle, style]}
62 >
63 {children}
64 </GlassView>
65 );
66 }
67
68 return (
69 <View {...props} style={[surfaceStyle, style]}>
70 {children}
71 </View>
72 );
73}
apps/mobile/src/components/LoadingScreen.tsx created+27
...@@ -0,0 +1,27 @@
1import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native";
2import { useSafeAreaInsets } from "react-native-safe-area-context";
3import { useThemeColor } from "../lib/useThemeColor";
4
5import { AppText as Text } from "./AppText";
6import { BrandMark } from "./BrandMark";
7
8export function LoadingScreen(props: { readonly message: string }) {
9 const colorScheme = useColorScheme();
10 const screenBg = useThemeColor("--color-screen");
11 const insets = useSafeAreaInsets();
12
13 return (
14 <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}>
15 <StatusBar
16 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
17 backgroundColor={screenBg as string}
18 translucent
19 />
20 <View className="flex-1 items-center justify-center gap-5 px-6">
21 <BrandMark compact />
22 <ActivityIndicator size="large" />
23 <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text>
24 </View>
25 </View>
26 );
27}
apps/mobile/src/components/ProjectFavicon.tsx created+70
...@@ -0,0 +1,70 @@
1import { SymbolView } from "expo-symbols";
2import { useState } from "react";
3import { Image, View } from "react-native";
4import { useThemeColor } from "../lib/useThemeColor";
5
6/* ─── Favicon cache (matches web pattern) ────────────────────────────── */
7const loadedFaviconUrls = new Set<string>();
8
9/* ─── Component ──────────────────────────────────────────────────────── */
10export function ProjectFavicon(props: {
11 readonly size?: number;
12 readonly projectTitle: string;
13 readonly httpBaseUrl?: string | null;
14 readonly workspaceRoot?: string | null;
15 readonly bearerToken?: string | null;
16}) {
17 const size = props.size ?? 42;
18 const iconMuted = useThemeColor("--color-icon-subtle");
19
20 const faviconUrl =
21 props.httpBaseUrl && props.workspaceRoot
22 ? `${props.httpBaseUrl}/api/project-favicon?cwd=${encodeURIComponent(props.workspaceRoot)}`
23 : null;
24
25 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
26 faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading",
27 );
28
29 const showImage = faviconUrl && status === "loaded";
30
31 return (
32 <View
33 style={{
34 width: size,
35 height: size,
36 alignItems: "center",
37 justifyContent: "center",
38 }}
39 >
40 {/* Folder icon fallback (matches web's FolderIcon) */}
41 {!showImage ? (
42 <SymbolView name="folder.fill" size={size * 0.78} tintColor={iconMuted} type="monochrome" />
43 ) : null}
44
45 {/* Favicon image (hidden until loaded) */}
46 {faviconUrl ? (
47 <Image
48 source={{
49 uri: faviconUrl,
50 ...(props.bearerToken
51 ? { headers: { Authorization: `Bearer ${props.bearerToken}` } }
52 : {}),
53 }}
54 style={{
55 width: size,
56 height: size,
57 borderRadius: size * 0.16,
58 ...(showImage ? {} : { position: "absolute" as const, opacity: 0 }),
59 }}
60 resizeMode="contain"
61 onLoad={() => {
62 if (faviconUrl) loadedFaviconUrls.add(faviconUrl);
63 setStatus("loaded");
64 }}
65 onError={() => setStatus("error")}
66 />
67 ) : null}
68 </View>
69 );
70}
apps/mobile/src/components/ProviderIcon.tsx created+32
...@@ -0,0 +1,32 @@
1import { useColorScheme } from "react-native";
2import { Path, Svg } from "react-native-svg";
3
4type ProviderIconProps = {
5 readonly provider: string | null | undefined;
6 readonly size?: number;
7};
8
9export function ProviderIcon(props: ProviderIconProps) {
10 const isDarkMode = useColorScheme() === "dark";
11 const size = props.size ?? 16;
12
13 if (props.provider === "claudeAgent") {
14 return (
15 <Svg width={size} height={size} viewBox="0 0 256 257" fill="none">
16 <Path
17 fill="#D97757"
18 d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
19 />
20 </Svg>
21 );
22 }
23
24 return (
25 <Svg width={size} height={size} viewBox="0 0 256 260" fill="none">
26 <Path
27 fill={isDarkMode ? "#e2e8f0" : "#171717"}
28 d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"
29 />
30 </Svg>
31 );
32}
apps/mobile/src/components/SourceControlIcon.tsx created+99
...@@ -0,0 +1,99 @@
1import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
2
3export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops";
4
5export function SourceControlIcon(props: {
6 readonly kind: SourceControlIconKind;
7 readonly size?: number;
8 readonly color?: string;
9}) {
10 const size = props.size ?? 18;
11
12 switch (props.kind) {
13 case "github":
14 return (
15 <Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
16 <Path
17 fillRule="evenodd"
18 clipRule="evenodd"
19 d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.68 7.68 0 0 1 8.02 3.86c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
20 fill={props.color ?? "#24292F"}
21 />
22 </Svg>
23 );
24 case "gitlab":
25 return (
26 <Svg width={size} height={size} viewBox="0 0 32 32" fill="none">
27 <Path
28 d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z"
29 fill="#E24329"
30 />
31 <Path
32 d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z"
33 fill="#FC6D26"
34 />
35 <Path
36 d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z"
37 fill="#FCA326"
38 />
39 <Path
40 d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z"
41 fill="#FC6D26"
42 />
43 </Svg>
44 );
45 case "azure-devops":
46 return (
47 <Svg width={size} height={size} viewBox="0 0 96 96">
48 <Defs>
49 <LinearGradient id="azure-a" x1="42.83" x2="15.79" y1="12.69" y2="92.57">
50 <Stop offset="0" stopColor="#114A8B" />
51 <Stop offset="1" stopColor="#0669BC" />
52 </LinearGradient>
53 <LinearGradient id="azure-b" x1="47.84" x2="77.52" y1="10.36" y2="89.44">
54 <Stop offset="0" stopColor="#3CCBF4" />
55 <Stop offset="1" stopColor="#2892DF" />
56 </LinearGradient>
57 </Defs>
58 <Path
59 fill="url(#azure-a)"
60 d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"
61 />
62 <Path
63 fill="#0078D4"
64 d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"
65 />
66 <Path
67 fill="url(#azure-b)"
68 d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"
69 />
70 </Svg>
71 );
72 case "bitbucket":
73 return (
74 <Svg width={size} height={size} viewBox="8.4 14.39 2481.29 2231.21">
75 <Defs>
76 <LinearGradient
77 id="bitbucket-a"
78 x1="945.1094"
79 y1="1524.8389"
80 x2="944.4923"
81 y2="1524.1893"
82 gradientTransform="matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)"
83 >
84 <Stop offset="0.18" stopColor="#0052CC" />
85 <Stop offset="1" stopColor="#2684FF" />
86 </LinearGradient>
87 </Defs>
88 <Path
89 fill="#2684FF"
90 d="M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z"
91 />
92 <Path
93 fill="url(#bitbucket-a)"
94 d="M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z"
95 />
96 </Svg>
97 );
98 }
99}
apps/mobile/src/components/StatusPill.tsx created+37
...@@ -0,0 +1,37 @@
1import { View } from "react-native";
2
3import { AppText as Text } from "./AppText";
4import { cn } from "../lib/cn";
5
6export interface StatusTone {
7 readonly label: string;
8 readonly pillClassName: string;
9 readonly textClassName: string;
10}
11
12export function StatusPill(
13 props: StatusTone & {
14 readonly size?: "default" | "compact";
15 },
16) {
17 const size = props.size ?? "default";
18 return (
19 <View
20 className={cn(
21 "rounded-full",
22 size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5",
23 props.pillClassName,
24 )}
25 >
26 <Text
27 className={cn(
28 "font-t3-bold",
29 size === "compact" ? "text-[11px]" : "text-xs",
30 props.textClassName,
31 )}
32 >
33 {props.label}
34 </Text>
35 </View>
36 );
37}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx created+190
...@@ -0,0 +1,190 @@
1import { SymbolView } from "expo-symbols";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { useCallback, useState } from "react";
4import { Pressable, View } from "react-native";
5import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
6import { useThemeColor } from "../../lib/useThemeColor";
7
8import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
9import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
10import { ConnectionStatusDot } from "./ConnectionStatusDot";
11
12function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
13 if (environment.connectionError) {
14 return null;
15 }
16
17 switch (environment.connectionState) {
18 case "ready":
19 return "Connected";
20 case "connecting":
21 return "Connecting";
22 case "reconnecting":
23 return "Reconnecting";
24 case "disconnected":
25 return null;
26 case "idle":
27 return null;
28 }
29}
30
31export function ConnectionEnvironmentRow(props: {
32 readonly environment: ConnectedEnvironmentSummary;
33 readonly expanded: boolean;
34 readonly onToggle: () => void;
35 readonly onReconnect: (environmentId: EnvironmentId) => void;
36 readonly onRemove: (environmentId: EnvironmentId) => void;
37 readonly onUpdate: (
38 environmentId: EnvironmentId,
39 updates: { readonly label: string; readonly displayUrl: string },
40 ) => void;
41}) {
42 const [label, setLabel] = useState(props.environment.environmentLabel);
43 const [url, setUrl] = useState(props.environment.displayUrl);
44
45 const mutedColor = useThemeColor("--color-icon-subtle");
46 const placeholderColor = useThemeColor("--color-placeholder");
47 const primaryFg = useThemeColor("--color-primary-foreground");
48 const dangerFg = useThemeColor("--color-danger-foreground");
49 const statusLabel = connectionStatusLabel(props.environment);
50
51 const handleSave = useCallback(() => {
52 props.onUpdate(props.environment.environmentId, {
53 label: label.trim(),
54 displayUrl: url.trim(),
55 });
56 props.onToggle();
57 }, [label, url, props]);
58
59 return (
60 <Animated.View layout={LinearTransition.duration(250)} className="bg-card">
61 <Pressable
62 className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70"
63 onPress={props.onToggle}
64 >
65 <ConnectionStatusDot
66 state={props.environment.connectionState}
67 pulse={
68 props.environment.connectionState === "connecting" ||
69 props.environment.connectionState === "reconnecting"
70 }
71 size={8}
72 />
73
74 <View className="flex-1 gap-0.5">
75 <Text
76 className="text-[16px] font-t3-bold leading-[21px] text-foreground"
77 numberOfLines={1}
78 >
79 {props.environment.environmentLabel}
80 </Text>
81 <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}>
82 {props.environment.displayUrl}
83 </Text>
84 {statusLabel ? (
85 <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}>
86 {statusLabel}
87 </Text>
88 ) : null}
89 {props.environment.connectionError ? (
90 <Text
91 className="text-[12px] leading-[16px] text-rose-500 dark:text-rose-400"
92 numberOfLines={2}
93 >
94 {props.environment.connectionError}
95 </Text>
96 ) : null}
97 </View>
98
99 <SymbolView
100 name="chevron.down"
101 size={12}
102 tintColor={mutedColor}
103 type="monochrome"
104 style={{
105 transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],
106 }}
107 />
108 </Pressable>
109
110 {props.expanded ? (
111 <Animated.View
112 entering={FadeIn.duration(200)}
113 exiting={FadeOut.duration(150)}
114 className="gap-3 px-4 pb-4"
115 >
116 <View className="gap-1.5">
117 <Text
118 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
119 style={{ letterSpacing: 0.8 }}
120 >
121 Label
122 </Text>
123 <TextInput
124 autoCapitalize="words"
125 autoCorrect={false}
126 placeholder="My MacBook"
127 placeholderTextColor={placeholderColor}
128 value={label}
129 onChangeText={setLabel}
130 className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
131 />
132 </View>
133
134 <View className="gap-1.5">
135 <Text
136 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
137 style={{ letterSpacing: 0.8 }}
138 >
139 URL
140 </Text>
141 <TextInput
142 autoCapitalize="none"
143 autoCorrect={false}
144 keyboardType="url"
145 placeholder="192.168.1.100:8080"
146 placeholderTextColor={placeholderColor}
147 value={url}
148 onChangeText={setUrl}
149 className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
150 />
151 </View>
152
153 <View className="flex-row gap-2">
154 <Pressable
155 className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5 active:opacity-70"
156 onPress={handleSave}
157 >
158 <SymbolView name="checkmark" size={13} tintColor={primaryFg} type="monochrome" />
159 <Text
160 className="text-[12px] font-t3-bold uppercase text-primary-foreground"
161 style={{ letterSpacing: 0.8 }}
162 >
163 Save
164 </Text>
165 </Pressable>
166
167 <Pressable
168 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70"
169 onPress={() => props.onReconnect(props.environment.environmentId)}
170 >
171 <SymbolView
172 name="arrow.clockwise"
173 size={14}
174 tintColor={mutedColor}
175 type="monochrome"
176 />
177 </Pressable>
178
179 <Pressable
180 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70"
181 onPress={() => props.onRemove(props.environment.environmentId)}
182 >
183 <SymbolView name="trash" size={14} tintColor={dangerFg} type="monochrome" />
184 </Pressable>
185 </View>
186 </Animated.View>
187 ) : null}
188 </Animated.View>
189 );
190}
apps/mobile/src/features/connection/ConnectionSheetButton.tsx created+114
...@@ -0,0 +1,114 @@
1import { SymbolView } from "expo-symbols";
2import { Platform, Pressable } from "react-native";
3import { useThemeColor } from "../../lib/useThemeColor";
4
5import { AppText as Text } from "../../components/AppText";
6import { cn } from "../../lib/cn";
7
8const CARD_SHADOW = Platform.select({
9 ios: {
10 shadowColor: "rgba(23,23,23,0.08)",
11 shadowOffset: { width: 0, height: 4 },
12 shadowOpacity: 1,
13 shadowRadius: 16,
14 },
15 android: { elevation: 3 },
16});
17
18const CARD_SHADOW_DARK = Platform.select({
19 ios: {
20 shadowColor: "#000",
21 shadowOffset: { width: 0, height: 2 },
22 shadowOpacity: 0.18,
23 shadowRadius: 8,
24 },
25 android: { elevation: 4 },
26});
27
28export { CARD_SHADOW, CARD_SHADOW_DARK };
29
30export function ConnectionSheetButton(props: {
31 readonly icon: React.ComponentProps<typeof SymbolView>["name"];
32 readonly label: string;
33 readonly disabled?: boolean;
34 readonly tone?: "primary" | "secondary" | "danger";
35 readonly compact?: boolean;
36 readonly onPress: () => void;
37}) {
38 const tone = props.tone ?? "secondary";
39
40 const primaryBg = useThemeColor("--color-primary");
41 const primaryFg = useThemeColor("--color-primary-foreground");
42 const dangerBg = useThemeColor("--color-danger");
43 const dangerBorderColor = useThemeColor("--color-danger-border");
44 const dangerFg = useThemeColor("--color-danger-foreground");
45 const secondaryBg = useThemeColor("--color-secondary");
46 const secondaryFg = useThemeColor("--color-secondary-foreground");
47 const borderColor = useThemeColor("--color-border");
48
49 const colors =
50 tone === "primary"
51 ? {
52 backgroundColor: primaryBg,
53 borderColor: "transparent",
54 textColor: primaryFg,
55 }
56 : tone === "danger"
57 ? {
58 backgroundColor: dangerBg,
59 borderColor: dangerBorderColor,
60 textColor: dangerFg,
61 }
62 : {
63 backgroundColor: secondaryBg,
64 borderColor: borderColor,
65 textColor: secondaryFg,
66 };
67
68 const primaryShadow =
69 tone === "primary"
70 ? Platform.select({
71 ios: {
72 shadowColor: "#000",
73 shadowOffset: { width: 0, height: 3 },
74 shadowOpacity: 0.14,
75 shadowRadius: 6,
76 },
77 android: { elevation: 3 },
78 })
79 : undefined;
80
81 return (
82 <Pressable
83 className={cn(
84 props.compact
85 ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5"
86 : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3",
87 )}
88 disabled={props.disabled}
89 onPress={props.onPress}
90 style={[
91 {
92 backgroundColor: colors.backgroundColor,
93 borderWidth: tone === "primary" ? 0 : 1,
94 borderColor: colors.borderColor,
95 opacity: props.disabled ? 0.5 : 1,
96 },
97 primaryShadow,
98 ]}
99 >
100 <SymbolView
101 name={props.icon}
102 size={props.compact ? 13 : 14}
103 tintColor={colors.textColor}
104 type="monochrome"
105 />
106 <Text
107 className="text-[12px] font-t3-bold uppercase"
108 style={{ color: colors.textColor, letterSpacing: 0.8 }}
109 >
110 {props.label}
111 </Text>
112 </Pressable>
113 );
114}
apps/mobile/src/features/connection/ConnectionStatusDot.tsx created+112
...@@ -0,0 +1,112 @@
1import { useEffect } from "react";
2import { View } from "react-native";
3import Animated, {
4 cancelAnimation,
5 Easing,
6 useAnimatedStyle,
7 useSharedValue,
8 withRepeat,
9 withTiming,
10} from "react-native-reanimated";
11
12import type { RemoteClientConnectionState } from "../../lib/connection";
13
14function statusDotTone(state: RemoteClientConnectionState): {
15 readonly dotColor: string;
16 readonly haloColor: string;
17} {
18 switch (state) {
19 case "ready":
20 return {
21 dotColor: "#34d399",
22 haloColor: "rgba(52,211,153,0.48)",
23 };
24 case "connecting":
25 case "reconnecting":
26 return {
27 dotColor: "#f59e0b",
28 haloColor: "rgba(245,158,11,0.5)",
29 };
30 case "idle":
31 case "disconnected":
32 return {
33 dotColor: "#ef4444",
34 haloColor: "rgba(239,68,68,0.48)",
35 };
36 }
37}
38
39function usePulseAnimation(pulse: boolean) {
40 const pulseProgress = useSharedValue(0);
41
42 useEffect(() => {
43 if (pulse) {
44 pulseProgress.value = withRepeat(
45 withTiming(1, {
46 duration: 1100,
47 easing: Easing.out(Easing.cubic),
48 }),
49 -1,
50 false,
51 );
52 return;
53 }
54
55 cancelAnimation(pulseProgress);
56 pulseProgress.value = withTiming(0, {
57 duration: 180,
58 easing: Easing.out(Easing.quad),
59 });
60 }, [pulse, pulseProgress]);
61
62 return pulseProgress;
63}
64
65export function ConnectionStatusDot(props: {
66 readonly state: RemoteClientConnectionState;
67 readonly pulse: boolean;
68 readonly size?: number;
69}) {
70 const pulseProgress = usePulseAnimation(props.pulse);
71 const tone = statusDotTone(props.state);
72 const dotSize = props.size ?? 10;
73 const haloSize = dotSize + 4;
74 const containerSize = haloSize + 4;
75
76 const haloStyle = useAnimatedStyle(() => ({
77 opacity: props.pulse ? 0.14 + (1 - pulseProgress.value) * 0.3 : 0,
78 transform: [{ scale: 0.78 + pulseProgress.value * 1.16 }],
79 }));
80
81 return (
82 <View
83 style={{
84 width: containerSize,
85 height: containerSize,
86 alignItems: "center",
87 justifyContent: "center",
88 }}
89 >
90 <Animated.View
91 style={[
92 haloStyle,
93 {
94 position: "absolute",
95 width: haloSize,
96 height: haloSize,
97 borderRadius: haloSize / 2,
98 backgroundColor: tone.haloColor,
99 },
100 ]}
101 />
102 <View
103 style={{
104 width: dotSize,
105 height: dotSize,
106 borderRadius: dotSize / 2,
107 backgroundColor: tone.dotColor,
108 }}
109 />
110 </View>
111 );
112}
apps/mobile/src/features/connection/connectionTone.ts created+37
...@@ -0,0 +1,37 @@
1import type { StatusTone } from "../../components/StatusPill";
2import type { RemoteClientConnectionState } from "../../lib/connection";
3
4export function connectionTone(state: RemoteClientConnectionState): StatusTone {
5 switch (state) {
6 case "ready":
7 return {
8 label: "Connected",
9 pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16",
10 textClassName: "text-emerald-700 dark:text-emerald-300",
11 };
12 case "reconnecting":
13 return {
14 label: "Reconnecting",
15 pillClassName: "bg-amber-500/12 dark:bg-amber-500/16",
16 textClassName: "text-amber-700 dark:text-amber-300",
17 };
18 case "connecting":
19 return {
20 label: "Connecting",
21 pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",
22 textClassName: "text-sky-700 dark:text-sky-300",
23 };
24 case "disconnected":
25 return {
26 label: "Disconnected",
27 pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
28 textClassName: "text-rose-700 dark:text-rose-300",
29 };
30 case "idle":
31 return {
32 label: "Idle",
33 pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16",
34 textClassName: "text-neutral-600 dark:text-neutral-300",
35 };
36 }
37}
apps/mobile/src/features/connection/pairing.test.ts created+38
...@@ -0,0 +1,38 @@
1import { describe, expect, it } from "vitest";
2
3import { extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
4
5describe("extractPairingUrlFromQrPayload", () => {
6 it("trims raw pairing urls from qr payloads", () => {
7 expect(
8 extractPairingUrlFromQrPayload(" https://remote.example.com/pair#token=pairing-token "),
9 ).toBe("https://remote.example.com/pair#token=pairing-token");
10 });
11
12 it("unwraps mobile deep links that carry an encoded pairing url", () => {
13 expect(
14 extractPairingUrlFromQrPayload(
15 "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token",
16 ),
17 ).toBe("https://remote.example.com/pair#token=pairing-token");
18 });
19
20 it("rejects empty qr payloads", () => {
21 expect(() => extractPairingUrlFromQrPayload(" ")).toThrow(
22 "Scanned QR code did not contain a pairing URL.",
23 );
24 });
25});
26
27describe("parsePairingUrl", () => {
28 it("reads hosted pairing links into backend host fields", () => {
29 expect(
30 parsePairingUrl(
31 "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%2F#token=pairing-token",
32 ),
33 ).toEqual({
34 host: "https://desktop.tailnet.ts.net",
35 code: "pairing-token",
36 });
37 });
38});
apps/mobile/src/features/connection/pairing.ts created+67
...@@ -0,0 +1,67 @@
1import { readHostedPairingRequest } from "@t3tools/shared/remote";
2
3const MOBILE_PAIRING_URL_PARAM = "pairingUrl";
4
5export function buildPairingUrl(host: string, code: string): string {
6 const h = host.trim();
7 const c = code.trim();
8 if (!h) return "";
9 if (!c) return h;
10
11 try {
12 const url = new URL(h.includes("://") ? h : `https://${h}`);
13 url.hash = new URLSearchParams([["token", c]]).toString();
14 return url.toString();
15 } catch {
16 return `${h}#token=${c}`;
17 }
18}
19
20export function parsePairingUrl(url: string): { host: string; code: string } {
21 const trimmed = url.trim();
22 if (!trimmed) return { host: "", code: "" };
23
24 try {
25 const parsed = new URL(trimmed);
26 const hostedPairingRequest = readHostedPairingRequest(parsed);
27 if (hostedPairingRequest) {
28 return {
29 host: hostedPairingRequest.host.replace(/\/$/, ""),
30 code: hostedPairingRequest.token,
31 };
32 }
33
34 const hashParams = new URLSearchParams(parsed.hash.slice(1));
35 const hashToken = hashParams.get("token");
36 const queryToken = parsed.searchParams.get("token");
37 const code = hashToken || queryToken || "";
38
39 parsed.hash = "";
40 parsed.search = "";
41 parsed.pathname = "/";
42 return { host: parsed.toString().replace(/\/$/, ""), code };
43 } catch {
44 return { host: trimmed, code: "" };
45 }
46}
47
48export function extractPairingUrlFromQrPayload(payload: string): string {
49 const trimmed = payload.trim();
50 if (!trimmed) {
51 throw new Error("Scanned QR code did not contain a pairing URL.");
52 }
53
54 try {
55 const url = new URL(trimmed);
56 if (url.protocol === "t3code:") {
57 const pairingUrl = url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? "";
58 if (pairingUrl.length > 0) {
59 return pairingUrl;
60 }
61 }
62 } catch {
63 // Treat non-URL payloads as raw pairing-url text so the normal input validation can decide.
64 }
65
66 return trimmed;
67}
apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts created+405
...@@ -0,0 +1,405 @@
1import { createHighlighterCore, type HighlighterCore } from "@shikijs/core";
2import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript";
3import bashLanguage from "@shikijs/langs/bash";
4import diffLanguage from "@shikijs/langs/diff";
5import javascriptLanguage from "@shikijs/langs/javascript";
6import jsonLanguage from "@shikijs/langs/json";
7import jsxLanguage from "@shikijs/langs/jsx";
8import tsxLanguage from "@shikijs/langs/tsx";
9import typescriptLanguage from "@shikijs/langs/typescript";
10import yamlLanguage from "@shikijs/langs/yaml";
11
12import type { NativeReviewDiffFile, NativeReviewDiffLanguage } from "./nativeReviewDiffTypes";
13import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface";
14
15export type NativeReviewDiffHighlightScheme = "light" | "dark";
16export type NativeReviewDiffHighlightEngine = "native" | "javascript";
17
18export interface NativeReviewDiffHighlighterHandle {
19 readonly engine: NativeReviewDiffHighlightEngine;
20 readonly tokenize: (
21 code: string,
22 options: { readonly lang: NativeReviewDiffLanguage; readonly theme: string },
23 ) => ReadonlyArray<ReadonlyArray<NativeReviewDiffToken>>;
24}
25
26interface NativeReviewDiffLineRow extends NativeReviewDiffRow {
27 readonly kind: "line";
28 readonly fileId: string;
29 readonly content: string;
30}
31
32export interface NativeReviewDiffTokenChunk {
33 readonly chunkIndex: number;
34 readonly fileId: string;
35 readonly filePath: string;
36 readonly language: NativeReviewDiffLanguage;
37 readonly lineCount: number;
38 readonly durationMs: number;
39 readonly tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>>;
40}
41
42export interface StreamNativeReviewDiffTokenInput {
43 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
44 readonly files: ReadonlyArray<NativeReviewDiffFile>;
45 readonly scheme: NativeReviewDiffHighlightScheme;
46 readonly engine?: NativeReviewDiffHighlightEngine;
47 readonly chunkSize?: number;
48 readonly signal?: AbortSignal;
49 readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void;
50}
51
52export interface HighlightNativeReviewDiffVisibleRowsInput {
53 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
54 readonly files: ReadonlyArray<NativeReviewDiffFile>;
55 readonly scheme: NativeReviewDiffHighlightScheme;
56 readonly engine?: NativeReviewDiffHighlightEngine;
57 readonly firstRowIndex: number;
58 readonly lastRowIndex: number;
59 readonly overscanRows?: number;
60 readonly maxRows?: number;
61 readonly alreadyHighlightedRowIds?: ReadonlySet<string>;
62 readonly signal?: AbortSignal;
63}
64
65const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500;
66const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160;
67const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360;
68
69const NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME = {
70 dark: "t3-pierre-dark",
71 light: "t3-pierre-light",
72} as const;
73
74const PIERRE_LIGHT_SHIKI_THEME = {
75 name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.light,
76 type: "light" as const,
77 fg: "#070707",
78 bg: "#ffffff",
79 settings: [
80 { settings: { foreground: "#070707", background: "#ffffff" } },
81 { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } },
82 {
83 scope: "keyword, storage, storage.type, keyword.operator.expression",
84 settings: { foreground: "#FC2B73" },
85 },
86 {
87 scope: "entity.name.function, support.function, meta.function-call",
88 settings: { foreground: "#7B43F8" },
89 },
90 {
91 scope: "entity.name.type, support.type, support.class",
92 settings: { foreground: "#C635E4" },
93 },
94 {
95 scope: "string, constant.character, punctuation.definition.string",
96 settings: { foreground: "#199F43" },
97 },
98 {
99 scope: "constant.numeric, constant.language, constant.other",
100 settings: { foreground: "#1CA1C7" },
101 },
102 {
103 scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key",
104 settings: { foreground: "#D47628" },
105 },
106 {
107 scope: "entity.name.tag, support.class.component",
108 settings: { foreground: "#199F43" },
109 },
110 {
111 scope: "punctuation, meta.brace, meta.delimiter",
112 settings: { foreground: "#79797F" },
113 },
114 { scope: "invalid", settings: { foreground: "#D52C36" } },
115 ],
116};
117
118const PIERRE_DARK_SHIKI_THEME = {
119 name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.dark,
120 type: "dark" as const,
121 fg: "#adadb1",
122 bg: "#0a0a0a",
123 settings: [
124 { settings: { foreground: "#adadb1", background: "#0a0a0a" } },
125 { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } },
126 {
127 scope: "keyword, storage, storage.type, keyword.operator.expression",
128 settings: { foreground: "#FF678D" },
129 },
130 {
131 scope: "entity.name.function, support.function, meta.function-call",
132 settings: { foreground: "#9D6AFB" },
133 },
134 {
135 scope: "entity.name.type, support.type, support.class",
136 settings: { foreground: "#D568EA" },
137 },
138 {
139 scope: "string, constant.character, punctuation.definition.string",
140 settings: { foreground: "#5ECC71" },
141 },
142 {
143 scope: "constant.numeric, constant.language, constant.other",
144 settings: { foreground: "#68CDF2" },
145 },
146 {
147 scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key",
148 settings: { foreground: "#FFA359" },
149 },
150 {
151 scope: "entity.name.tag, support.class.component",
152 settings: { foreground: "#5ECC71" },
153 },
154 {
155 scope: "punctuation, meta.brace, meta.delimiter",
156 settings: { foreground: "#79797F" },
157 },
158 { scope: "invalid", settings: { foreground: "#FF6762" } },
159 ],
160};
161
162const NATIVE_REVIEW_DIFF_SHIKI_THEMES = [
163 PIERRE_LIGHT_SHIKI_THEME,
164 PIERRE_DARK_SHIKI_THEME,
165] satisfies Parameters<typeof createHighlighterCore>[0]["themes"];
166
167const NATIVE_REVIEW_DIFF_LANGUAGES = [
168 bashLanguage,
169 diffLanguage,
170 javascriptLanguage,
171 jsonLanguage,
172 jsxLanguage,
173 tsxLanguage,
174 typescriptLanguage,
175 yamlLanguage,
176] satisfies Parameters<typeof createHighlighterCore>[0]["langs"];
177
178let nativeHighlighterPromise: Promise<NativeReviewDiffHighlighterHandle> | null = null;
179let javascriptHighlighterPromise: Promise<NativeReviewDiffHighlighterHandle> | null = null;
180
181function waitForNextFrame(): Promise<void> {
182 return new Promise((resolve) => setTimeout(resolve, 0));
183}
184
185function normalizeTokens(
186 tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>,
187): ReadonlyArray<ReadonlyArray<NativeReviewDiffToken>> {
188 return tokenLines.map((line) =>
189 line.map((token) => ({
190 content: token.content,
191 color: token.color ?? null,
192 fontStyle: token.fontStyle ?? null,
193 })),
194 );
195}
196
197async function createNativeReviewDiffHighlighter(): Promise<NativeReviewDiffHighlighterHandle> {
198 const nativeEngineModule = await import("react-native-shiki-engine");
199 if (!nativeEngineModule.isNativeEngineAvailable()) {
200 throw new Error("Native Shiki engine is not available in this build.");
201 }
202
203 const highlighter = await createHighlighterCore({
204 langs: NATIVE_REVIEW_DIFF_LANGUAGES,
205 themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES,
206 engine: nativeEngineModule.createNativeEngine(),
207 });
208
209 return {
210 engine: "native",
211 tokenize: (code, options) => normalizeTokens(highlighter.codeToTokensBase(code, options)),
212 };
213}
214
215async function createJavascriptReviewDiffHighlighter(): Promise<NativeReviewDiffHighlighterHandle> {
216 const highlighter: HighlighterCore = await createHighlighterCore({
217 langs: NATIVE_REVIEW_DIFF_LANGUAGES,
218 themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES,
219 engine: createJavaScriptRegexEngine(),
220 });
221
222 return {
223 engine: "javascript",
224 tokenize: (code, options) => normalizeTokens(highlighter.codeToTokensBase(code, options)),
225 };
226}
227
228export async function getNativeReviewDiffHighlighter(
229 engine: NativeReviewDiffHighlightEngine = "native",
230): Promise<NativeReviewDiffHighlighterHandle> {
231 if (engine === "javascript") {
232 javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter();
233 return javascriptHighlighterPromise;
234 }
235
236 nativeHighlighterPromise ??= createNativeReviewDiffHighlighter().catch((error: unknown) => {
237 console.warn("[debug-native-diff] native highlighter unavailable", {
238 error: error instanceof Error ? error.message : String(error),
239 });
240 javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter();
241 return javascriptHighlighterPromise;
242 });
243 return nativeHighlighterPromise;
244}
245
246function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow {
247 return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string";
248}
249
250function groupLineRowsByFileId(rows: ReadonlyArray<NativeReviewDiffRow>) {
251 const rowsByFileId = new Map<string, NativeReviewDiffLineRow[]>();
252 for (const row of rows) {
253 if (!isHighlightableLineRow(row)) {
254 continue;
255 }
256
257 const fileRows = rowsByFileId.get(row.fileId) ?? [];
258 fileRows.push(row);
259 rowsByFileId.set(row.fileId, fileRows);
260 }
261 return rowsByFileId;
262}
263
264function createFileMap(files: ReadonlyArray<NativeReviewDiffFile>) {
265 return new Map(files.map((file) => [file.id, file]));
266}
267
268function clampRowIndex(index: number, rows: ReadonlyArray<NativeReviewDiffRow>) {
269 if (rows.length === 0) {
270 return 0;
271 }
272
273 return Math.min(rows.length - 1, Math.max(0, Math.floor(index)));
274}
275
276function makePlainTokenFallback(
277 row: NativeReviewDiffLineRow,
278): ReadonlyArray<NativeReviewDiffToken> {
279 return [{ content: row.content || " ", color: null, fontStyle: null }];
280}
281
282export async function highlightNativeReviewDiffVisibleRows(
283 input: HighlightNativeReviewDiffVisibleRowsInput,
284): Promise<{
285 readonly engine: NativeReviewDiffHighlightEngine;
286 readonly tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>>;
287 readonly rowCount: number;
288 readonly durationMs: number;
289}> {
290 const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native");
291 if (input.signal?.aborted) {
292 return { engine: highlighter.engine, tokensByRowId: {}, rowCount: 0, durationMs: 0 };
293 }
294
295 const startedAt = performance.now();
296 const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme];
297 const fileMap = createFileMap(input.files);
298 const overscanRows = input.overscanRows ?? NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS;
299 const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS;
300 const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows);
301 const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows);
302 const selectedRows: NativeReviewDiffLineRow[] = [];
303
304 for (
305 let rowIndex = startIndex;
306 rowIndex <= endIndex && selectedRows.length < maxRows;
307 rowIndex += 1
308 ) {
309 const row = input.rows[rowIndex];
310 if (
311 row &&
312 isHighlightableLineRow(row) &&
313 !input.alreadyHighlightedRowIds?.has(row.id) &&
314 fileMap.has(row.fileId)
315 ) {
316 selectedRows.push(row);
317 }
318 }
319
320 const tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>> = {};
321 let segmentRows: NativeReviewDiffLineRow[] = [];
322 let segmentFile: NativeReviewDiffFile | undefined;
323
324 const flushSegment = () => {
325 if (!segmentFile || segmentRows.length === 0 || input.signal?.aborted) {
326 segmentRows = [];
327 segmentFile = undefined;
328 return;
329 }
330
331 const code = segmentRows.map((row) => row.content).join("\n");
332 const tokenLines = highlighter.tokenize(code, { lang: segmentFile.language, theme });
333 segmentRows.forEach((row, rowIndex) => {
334 tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row);
335 });
336 segmentRows = [];
337 segmentFile = undefined;
338 };
339
340 for (const row of selectedRows) {
341 const file = fileMap.get(row.fileId);
342 if (!file) {
343 continue;
344 }
345
346 if (segmentFile && segmentFile.id !== file.id) {
347 flushSegment();
348 }
349
350 segmentFile = file;
351 segmentRows.push(row);
352 }
353 flushSegment();
354
355 return {
356 engine: highlighter.engine,
357 tokensByRowId,
358 rowCount: selectedRows.length,
359 durationMs: Math.round(performance.now() - startedAt),
360 };
361}
362
363export async function streamNativeReviewDiffTokens(
364 input: StreamNativeReviewDiffTokenInput,
365): Promise<NativeReviewDiffHighlightEngine> {
366 const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native");
367 const rowsByFileId = groupLineRowsByFileId(input.rows);
368 const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme];
369 const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE;
370 let chunkIndex = 0;
371
372 for (const file of input.files) {
373 const fileRows = rowsByFileId.get(file.id) ?? [];
374 for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) {
375 if (input.signal?.aborted) {
376 return highlighter.engine;
377 }
378
379 const startedAt = performance.now();
380 const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize);
381 const code = chunkRows.map((row) => row.content).join("\n");
382 const tokenLines = highlighter.tokenize(code, { lang: file.language, theme });
383 const tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>> = {};
384
385 chunkRows.forEach((row, rowIndex) => {
386 tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row);
387 });
388
389 input.onChunk({
390 chunkIndex,
391 fileId: file.id,
392 filePath: file.path,
393 language: file.language,
394 lineCount: chunkRows.length,
395 durationMs: Math.round(performance.now() - startedAt),
396 tokensByRowId,
397 });
398
399 chunkIndex += 1;
400 await waitForNextFrame();
401 }
402 }
403
404 return highlighter.engine;
405}
apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts created+67
...@@ -0,0 +1,67 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3const expoMocks = vi.hoisted(() => ({
4 requireNativeView: vi.fn(),
5}));
6const nativeView = () => null;
7const originalExpo = globalThis.expo;
8
9function setExpoViewConfigAvailable() {
10 globalThis.expo = {
11 getViewConfig: vi.fn().mockReturnValue({ validAttributes: {}, directEventTypes: {} }),
12 } as unknown as typeof globalThis.expo;
13}
14
15vi.mock("expo", () => ({
16 requireNativeView: expoMocks.requireNativeView,
17}));
18
19describe("resolveNativeReviewDiffView", () => {
20 beforeEach(() => {
21 vi.clearAllMocks();
22 vi.resetModules();
23 globalThis.expo = undefined as unknown as typeof globalThis.expo;
24 });
25
26 afterEach(() => {
27 globalThis.expo = originalExpo;
28 });
29
30 it("returns null when the native review diff view config is unavailable", async () => {
31 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
32 expect(resolveNativeReviewDiffView()).toBeNull();
33 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();
34 });
35
36 it("returns the native review diff view when the view config is installed", async () => {
37 setExpoViewConfigAvailable();
38 expoMocks.requireNativeView.mockReturnValue(nativeView);
39 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
40 expect(resolveNativeReviewDiffView()).toBe(nativeView);
41 expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface");
42 });
43
44 it("does not fall back to stale legacy native review diff view names", async () => {
45 globalThis.expo = {
46 getViewConfig: vi.fn().mockImplementation((moduleName: string) => {
47 if (moduleName === "T3ReviewDiffView") {
48 return { validAttributes: {}, directEventTypes: {} };
49 }
50 return null;
51 }),
52 } as unknown as typeof globalThis.expo;
53 expoMocks.requireNativeView.mockReturnValue(nativeView);
54 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
55 expect(resolveNativeReviewDiffView()).toBeNull();
56 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();
57 });
58
59 it("returns null when the view manager cannot be required", async () => {
60 setExpoViewConfigAvailable();
61 expoMocks.requireNativeView.mockImplementation(() => {
62 throw new Error("boom");
63 });
64 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
65 expect(resolveNativeReviewDiffView()).toBeNull();
66 });
67});
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts created+155
...@@ -0,0 +1,155 @@
1import type { ComponentType } from "react";
2import type { NativeSyntheticEvent, ViewProps } from "react-native";
3import { requireNativeView } from "expo";
4
5const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";
6
7interface ExpoGlobalWithViewConfig {
8 readonly expo?: {
9 getViewConfig?: (moduleName: string, viewName?: string) => unknown;
10 };
11}
12
13export interface NativeReviewDiffRow {
14 readonly kind: "file" | "hunk" | "line" | "notice" | "comment";
15 readonly id: string;
16 readonly fileId?: string;
17 readonly filePath?: string;
18 readonly previousPath?: string | null;
19 readonly changeType?:
20 | "modified"
21 | "new"
22 | "deleted"
23 | "renamed"
24 | "rename-pure"
25 | "rename-changed";
26 readonly additions?: number;
27 readonly deletions?: number;
28 readonly text?: string;
29 readonly content?: string;
30 readonly change?: "context" | "add" | "delete";
31 readonly oldLineNumber?: number | null;
32 readonly newLineNumber?: number | null;
33 readonly wordDiffRanges?: ReadonlyArray<NativeReviewDiffWordDiffRange>;
34 readonly commentText?: string;
35 readonly commentRangeLabel?: string;
36 readonly commentSectionTitle?: string;
37}
38
39export interface NativeReviewDiffWordDiffRange {
40 readonly start: number;
41 readonly end: number;
42}
43
44export interface NativeReviewDiffToken {
45 readonly content: string;
46 readonly color: string | null;
47 readonly fontStyle: number | null;
48}
49
50export interface NativeReviewDiffTheme {
51 readonly background: string;
52 readonly text: string;
53 readonly mutedText: string;
54 readonly headerBackground: string;
55 readonly border: string;
56 readonly hunkBackground: string;
57 readonly hunkText: string;
58 readonly addBackground: string;
59 readonly deleteBackground: string;
60 readonly addBar: string;
61 readonly deleteBar: string;
62 readonly addText: string;
63 readonly deleteText: string;
64}
65
66export interface NativeReviewDiffStyle {
67 readonly rowHeight?: number;
68 readonly contentWidth?: number;
69 readonly changeBarWidth?: number;
70 readonly gutterWidth?: number;
71 readonly codePadding?: number;
72 readonly textVerticalInset?: number;
73 readonly fileHeaderHeight?: number;
74 readonly fileHeaderHorizontalMargin?: number;
75 readonly fileHeaderVerticalMargin?: number;
76 readonly fileHeaderCornerRadius?: number;
77 readonly fileHeaderHorizontalPadding?: number;
78 readonly fileHeaderPathRightPadding?: number;
79 readonly fileHeaderCountColumnWidth?: number;
80 readonly fileHeaderCountGap?: number;
81 readonly codeFontSize?: number;
82 readonly codeFontWeight?: string;
83 readonly lineNumberFontSize?: number;
84 readonly lineNumberFontWeight?: string;
85 readonly hunkFontSize?: number;
86 readonly hunkFontWeight?: string;
87 readonly fileHeaderFontSize?: number;
88 readonly fileHeaderFontWeight?: string;
89 readonly fileHeaderMetaFontSize?: number;
90 readonly fileHeaderMetaFontWeight?: string;
91 readonly fileHeaderSubtextFontSize?: number;
92 readonly fileHeaderSubtextFontWeight?: string;
93 readonly fileHeaderStatusFontSize?: number;
94 readonly fileHeaderStatusFontWeight?: string;
95 readonly emptyStateFontSize?: number;
96 readonly emptyStateFontWeight?: string;
97}
98
99export interface NativeReviewDiffViewProps extends ViewProps {
100 readonly rowsJson: string;
101 readonly tokensJson?: string;
102 readonly tokensPatchJson?: string;
103 readonly tokensResetKey?: string;
104 readonly collapsedFileIdsJson?: string;
105 readonly viewedFileIdsJson?: string;
106 readonly selectedRowIdsJson?: string;
107 readonly collapsedCommentIdsJson?: string;
108 readonly appearanceScheme: "light" | "dark";
109 readonly themeJson: string;
110 readonly styleJson?: string;
111 readonly rowHeight: number;
112 readonly contentWidth: number;
113 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
114 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
115 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
116 readonly onPressLine?: (
117 event: NativeSyntheticEvent<{
118 readonly rowId?: string;
119 readonly fileId?: string;
120 readonly gesture?: "tap" | "longPress";
121 readonly oldLineNumber?: number;
122 readonly newLineNumber?: number;
123 readonly change?: "context" | "add" | "delete";
124 }>,
125 ) => void;
126 readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;
127}
128
129let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined;
130
131function getExpoViewConfig(moduleName: string) {
132 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
133 moduleName,
134 );
135}
136
137export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {
138 if (cachedNativeReviewDiffView) {
139 return cachedNativeReviewDiffView;
140 }
141
142 if (getExpoViewConfig(NATIVE_REVIEW_DIFF_MODULE_NAME) == null) {
143 return null;
144 }
145
146 try {
147 cachedNativeReviewDiffView = requireNativeView<NativeReviewDiffViewProps>(
148 NATIVE_REVIEW_DIFF_MODULE_NAME,
149 );
150 } catch {
151 return null;
152 }
153
154 return cachedNativeReviewDiffView ?? null;
155}
apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts created+17
...@@ -0,0 +1,17 @@
1export type NativeReviewDiffLanguage =
2 | "bash"
3 | "diff"
4 | "javascript"
5 | "json"
6 | "jsx"
7 | "tsx"
8 | "typescript"
9 | "yaml";
10
11export interface NativeReviewDiffFile {
12 readonly id: string;
13 readonly path: string;
14 readonly language: NativeReviewDiffLanguage;
15 readonly additions: number;
16 readonly deletions: number;
17}
apps/mobile/src/features/home/HomeScreen.tsx created+426
...@@ -0,0 +1,426 @@
1import type {
2 EnvironmentScopedProjectShell,
3 EnvironmentScopedThreadShell,
4 VcsStatusState,
5} from "@t3tools/client-runtime";
6import { SymbolView } from "expo-symbols";
7import { useCallback, useMemo, useState } from "react";
8import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
9import * as Arr from "effect/Array";
10import * as Order from "effect/Order";
11import { useThemeColor } from "../../lib/useThemeColor";
12
13import { AppText as Text } from "../../components/AppText";
14import { EmptyState } from "../../components/EmptyState";
15import { ProjectFavicon } from "../../components/ProjectFavicon";
16import type { SavedRemoteConnection } from "../../lib/connection";
17import { scopedProjectKey } from "../../lib/scopedEntities";
18import { relativeTime } from "../../lib/time";
19import type { RemoteCatalogState } from "../../state/use-remote-catalog";
20import { useVcsStatus } from "../../state/use-vcs-status";
21import { threadStatusTone } from "../threads/threadPresentation";
22
23/* ─── Types ──────────────────────────────────────────────────────────── */
24
25interface HomeScreenProps {
26 readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>;
27 readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>;
28 readonly catalogState: RemoteCatalogState;
29 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
30 readonly searchQuery: string;
31 readonly onAddConnection: () => void;
32 readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void;
33}
34
35interface ProjectGroup {
36 readonly key: string;
37 readonly project: EnvironmentScopedProjectShell;
38 readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>;
39}
40
41const projectGroupActivityOrder = Order.mapInput(
42 Order.Struct({
43 activityAt: Order.flip(Order.Number),
44 }),
45 (group: ProjectGroup) => ({
46 activityAt: new Date(group.threads[0]!.updatedAt ?? group.threads[0]!.createdAt).getTime(),
47 }),
48);
49
50/* ─── Status indicator colors ────────────────────────────────────────── */
51
52function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } {
53 switch (thread.session?.status) {
54 case "running":
55 return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" };
56 case "ready":
57 return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" };
58 case "starting":
59 return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" };
60 case "error":
61 return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" };
62 default:
63 return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" };
64 }
65}
66
67const COLLAPSED_THREAD_LIMIT = 6;
68
69function deriveEmptyState(props: {
70 readonly catalogState: RemoteCatalogState;
71 readonly projectCount: number;
72}): { readonly title: string; readonly detail: string; readonly loading: boolean } {
73 const { catalogState } = props;
74 if (catalogState.isLoadingSavedConnections) {
75 return {
76 title: "Loading environments",
77 detail: "Checking saved environments on this device.",
78 loading: true,
79 };
80 }
81
82 if (!catalogState.hasSavedConnections) {
83 return {
84 title: "No environments connected",
85 detail: "Add an environment to load projects and start coding sessions.",
86 loading: false,
87 };
88 }
89
90 if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) {
91 return {
92 title: "Environment unavailable",
93 detail:
94 catalogState.connectionError ??
95 "The saved environment is offline. Check the URL or start the environment, then retry.",
96 loading: false,
97 };
98 }
99
100 if (
101 catalogState.hasConnectingEnvironment &&
102 !catalogState.hasLoadedShellSnapshot &&
103 catalogState.connectionError === null
104 ) {
105 return {
106 title: "Connecting to environment",
107 detail: "Loading projects and threads from the saved environment.",
108 loading: true,
109 };
110 }
111
112 if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) {
113 return {
114 title: "No projects found",
115 detail: "The connected environment did not report any projects.",
116 loading: false,
117 };
118 }
119
120 return {
121 title: "No threads yet",
122 detail: "Create a task to start a new coding session in one of your connected projects.",
123 loading: false,
124 };
125}
126
127/* ─── Project group header ───────────────────────────────────────────── */
128
129function ProjectGroupLabel(props: {
130 readonly project: EnvironmentScopedProjectShell;
131 readonly totalThreadCount: number;
132 readonly httpBaseUrl: string | null;
133 readonly bearerToken: string | null;
134 readonly isExpanded: boolean;
135 readonly onToggleExpand: () => void;
136}) {
137 const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT;
138
139 return (
140 <View className="flex-row items-center gap-2.5 px-1 pb-2">
141 <ProjectFavicon
142 size={18}
143 projectTitle={props.project.title}
144 httpBaseUrl={props.httpBaseUrl}
145 workspaceRoot={props.project.workspaceRoot}
146 bearerToken={props.bearerToken}
147 />
148 <Text
149 className="flex-1 text-[13px] font-t3-bold uppercase text-foreground-muted"
150 style={{ letterSpacing: 0.6 }}
151 numberOfLines={1}
152 >
153 {props.project.title}
154 </Text>
155
156 {hiddenCount > 0 ? (
157 <Pressable onPress={props.onToggleExpand} hitSlop={8}>
158 <Text
159 className="text-[13px] font-t3-bold text-foreground-muted"
160 style={{ letterSpacing: 0.6 }}
161 >
162 {props.isExpanded ? "Show less" : `${hiddenCount} more`}
163 </Text>
164 </Pressable>
165 ) : null}
166 </View>
167 );
168}
169
170/* ─── Git summary line ──────────────────────────────────────────────── */
171
172function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray<string> {
173 if (!gitStatus.data) return [];
174 const { data } = gitStatus;
175 const parts: string[] = [];
176 if (data.hasWorkingTreeChanges) {
177 parts.push(`${data.workingTree.files.length} changed`);
178 }
179 if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`);
180 if (data.behindCount > 0) parts.push(`${data.behindCount} behind`);
181 if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`);
182 return parts;
183}
184
185/* ─── Thread row ─────────────────────────────────────────────────────── */
186
187function ThreadRow(props: {
188 readonly thread: EnvironmentScopedThreadShell;
189 readonly projectCwd: string | null;
190 readonly onPress: () => void;
191 readonly isLast: boolean;
192}) {
193 const separatorColor = useThemeColor("--color-separator");
194 const { bg, fg } = statusColors(props.thread);
195 const tone = threadStatusTone(props.thread);
196 const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt);
197 const branch = props.thread.branch;
198
199 // Subscribe to live git status — only when thread has a branch set.
200 // Threads sharing the same cwd share one WS subscription via ref-counting.
201 const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null;
202 const gitStatus = useVcsStatus({
203 environmentId: cwd ? props.thread.environmentId : null,
204 cwd,
205 });
206 const gitParts = gitSummaryParts(gitStatus);
207
208 return (
209 <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}>
210 <View
211 style={{
212 flexDirection: "row",
213 paddingLeft: 16,
214 paddingRight: 16,
215 paddingVertical: 10,
216 gap: 12,
217 borderBottomWidth: props.isLast ? 0 : 1,
218 borderBottomColor: separatorColor,
219 }}
220 >
221 {/* Git status indicator */}
222 <View
223 style={{
224 width: 30,
225 height: 30,
226 borderRadius: 9,
227 backgroundColor: bg,
228 alignItems: "center",
229 justifyContent: "center",
230 marginTop: 2,
231 }}
232 >
233 <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />
234 </View>
235
236 {/* Content */}
237 <View style={{ flex: 1, gap: 3 }}>
238 {/* Title + Status + Timestamp */}
239 <View className="flex-row items-center justify-between gap-2">
240 <Text
241 className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground"
242 numberOfLines={1}
243 >
244 {props.thread.title}
245 </Text>
246 <View className="flex-row items-center gap-2">
247 <View
248 className={tone.pillClassName}
249 style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }}
250 >
251 <Text className={`text-[10px] font-t3-bold ${tone.textClassName}`}>
252 {tone.label}
253 </Text>
254 </View>
255 <Text
256 className="text-[12px] text-foreground-tertiary"
257 style={{ fontVariant: ["tabular-nums"] }}
258 >
259 {timestamp}
260 </Text>
261 </View>
262 </View>
263
264 {/* Branch + git info */}
265 {branch ? (
266 <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}>
267 <SymbolView
268 name="arrow.triangle.branch"
269 size={10}
270 tintColor="#737373"
271 type="monochrome"
272 />
273 <Text
274 className="text-[11px] text-foreground-tertiary"
275 numberOfLines={1}
276 style={{ fontFamily: "monospace" }}
277 >
278 {branch}
279 </Text>
280 {gitParts.length > 0 ? (
281 <Text className="text-[11px] text-foreground-tertiary">
282 {" · " + gitParts.join(" · ")}
283 </Text>
284 ) : null}
285 </View>
286 ) : null}
287 </View>
288 </View>
289 </Pressable>
290 );
291}
292
293/* ─── Main screen ────────────────────────────────────────────────────── */
294
295export function HomeScreen(props: HomeScreenProps) {
296 const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set());
297 const accentColor = useThemeColor("--color-icon-muted");
298
299 const toggleExpanded = useCallback((key: string) => {
300 setExpandedProjects((prev) => {
301 const next = new Set(prev);
302 if (next.has(key)) next.delete(key);
303 else next.add(key);
304 return next;
305 });
306 }, []);
307
308 /* Build project title lookup for search */
309 const projectTitleByKey = useMemo(() => {
310 const map = new Map<string, string>();
311 for (const p of props.projects) {
312 map.set(scopedProjectKey(p.environmentId, p.id), p.title);
313 }
314 return map;
315 }, [props.projects]);
316
317 /* Filter threads by search query */
318 const filteredThreads = useMemo(() => {
319 const q = props.searchQuery.trim().toLowerCase();
320 if (!q) return props.threads;
321 return props.threads.filter((t) => {
322 if (t.title.toLowerCase().includes(q)) return true;
323 const key = scopedProjectKey(t.environmentId, t.projectId);
324 return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false;
325 });
326 }, [props.threads, props.searchQuery, projectTitleByKey]);
327
328 /* Group filtered threads by project */
329 const projectGroups = useMemo<ReadonlyArray<ProjectGroup>>(() => {
330 const byProject = new Map<string, EnvironmentScopedThreadShell[]>();
331 for (const thread of filteredThreads) {
332 const key = scopedProjectKey(thread.environmentId, thread.projectId);
333 const existing = byProject.get(key);
334 if (existing) existing.push(thread);
335 else byProject.set(key, [thread]);
336 }
337
338 const groups: ProjectGroup[] = [];
339 for (const project of props.projects) {
340 const key = scopedProjectKey(project.environmentId, project.id);
341 const threads = byProject.get(key);
342 if (threads && threads.length > 0) {
343 groups.push({ key, project, threads });
344 }
345 }
346
347 return Arr.sort(groups, projectGroupActivityOrder);
348 }, [props.projects, filteredThreads]);
349
350 /* Empty states */
351 const hasAnyThreads = props.threads.length > 0;
352 const hasResults = filteredThreads.length > 0;
353 const emptyState = deriveEmptyState({
354 catalogState: props.catalogState,
355 projectCount: props.projects.length,
356 });
357
358 return (
359 <ScrollView
360 contentInsetAdjustmentBehavior="automatic"
361 showsVerticalScrollIndicator={false}
362 keyboardDismissMode="on-drag"
363 keyboardShouldPersistTaps="handled"
364 className="flex-1 bg-screen"
365 contentContainerStyle={{
366 paddingHorizontal: 16,
367 paddingTop: 8,
368 paddingBottom: 24,
369 gap: 20,
370 }}
371 >
372 {!hasAnyThreads ? (
373 <View>
374 <EmptyState
375 title={emptyState.title}
376 detail={emptyState.detail}
377 actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
378 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
379 />
380 {emptyState.loading ? (
381 <View className="absolute right-5 top-5">
382 <ActivityIndicator color={accentColor} />
383 </View>
384 ) : null}
385 </View>
386 ) : !hasResults ? (
387 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
388 ) : (
389 projectGroups.map((group) => {
390 const connection = props.savedConnectionsById[group.project.environmentId];
391 const isExpanded = expandedProjects.has(group.key);
392 const visibleThreads = isExpanded
393 ? group.threads
394 : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
395
396 return (
397 <View key={group.key}>
398 <ProjectGroupLabel
399 project={group.project}
400 totalThreadCount={group.threads.length}
401 httpBaseUrl={connection?.httpBaseUrl ?? null}
402 bearerToken={connection?.bearerToken ?? null}
403 isExpanded={isExpanded}
404 onToggleExpand={() => toggleExpanded(group.key)}
405 />
406 <View
407 className="overflow-hidden rounded-[20px] bg-card"
408 style={{ borderCurve: "continuous" }}
409 >
410 {visibleThreads.map((thread, i) => (
411 <ThreadRow
412 key={`${thread.environmentId}:${thread.id}`}
413 thread={thread}
414 projectCwd={group.project.workspaceRoot}
415 onPress={() => props.onSelectThread(thread)}
416 isLast={i === visibleThreads.length - 1}
417 />
418 ))}
419 </View>
420 </View>
421 );
422 })
423 )}
424 </ScrollView>
425 );
426}
apps/mobile/src/features/projects/AddProjectScreen.tsx created+827
...@@ -0,0 +1,827 @@
1import {
2 addProjectRemoteSourceLabel,
3 addProjectRemoteSourcePathHint,
4 addProjectRemoteSourceProvider,
5 appendBrowsePathSegment,
6 buildAddProjectRemoteSourceReadiness,
7 buildProjectCreateCommand,
8 canNavigateUp,
9 ensureBrowseDirectoryPath,
10 findExistingAddProject,
11 getAddProjectInitialQuery,
12 getBrowseDirectoryPath,
13 getBrowseLeafPathSegment,
14 getBrowseParentPath,
15 hasTrailingPathSeparator,
16 inferProjectTitleFromPath,
17 isFilesystemBrowseQuery,
18 resolveAddProjectPath,
19 sortAddProjectProviderSources,
20 type AddProjectRemoteSource,
21} from "@t3tools/client-runtime";
22import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
23import { useLocalSearchParams, useRouter } from "expo-router";
24import { SymbolView } from "expo-symbols";
25import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
26import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
27import { useSafeAreaInsets } from "react-native-safe-area-context";
28import * as Arr from "effect/Array";
29import * as Order from "effect/Order";
30
31import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
32import { ErrorBanner } from "../../components/ErrorBanner";
33import { SourceControlIcon } from "../../components/SourceControlIcon";
34import { useThemeColor } from "../../lib/useThemeColor";
35import { uuidv4 } from "../../lib/uuid";
36import { getEnvironmentClient } from "../../state/environment-session-registry";
37import { useFilesystemBrowse } from "../../state/use-filesystem-browse";
38import { useRemoteCatalog } from "../../state/use-remote-catalog";
39import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
40import {
41 refreshSourceControlDiscoveryForEnvironment,
42 useSourceControlDiscovery,
43} from "../../state/use-source-control-discovery";
44import { NewTaskSheetHeader } from "../threads/NewTaskSheetHeader";
45
46interface EnvironmentOption {
47 readonly environmentId: EnvironmentId;
48 readonly label: string;
49 readonly platform: string;
50 readonly baseDirectory: string | null;
51}
52
53const environmentOptionOrder = Order.mapInput(
54 Order.Struct({
55 label: Order.String,
56 }),
57 (environment: EnvironmentOption) => ({ label: environment.label }),
58);
59
60const browseEntryOrder = Order.mapInput(
61 Order.String,
62 (entry: { readonly name: string }) => entry.name,
63);
64
65function platformFromOs(os: string | null | undefined): string {
66 if (os === "windows") return "Win32";
67 if (os === "darwin") return "MacIntel";
68 if (os === "linux") return "Linux";
69 return "";
70}
71
72function errorMessage(error: unknown): string {
73 return error instanceof Error && error.message.trim().length > 0
74 ? error.message
75 : "An error occurred.";
76}
77
78function stringParam(value: string | string[] | undefined): string | null {
79 if (Array.isArray(value)) return value[0] ?? null;
80 return value ?? null;
81}
82
83function sourceFromParam(value: string | string[] | undefined): AddProjectRemoteSource {
84 const source = stringParam(value);
85 if (
86 source === "url" ||
87 source === "github" ||
88 source === "gitlab" ||
89 source === "bitbucket" ||
90 source === "azure-devops"
91 ) {
92 return source;
93 }
94 return "url";
95}
96
97function SectionTitle(props: { readonly children: string }) {
98 return (
99 <Text
100 className="px-1 text-[11px] font-t3-bold uppercase text-foreground-muted"
101 style={{ letterSpacing: 0.7 }}
102 >
103 {props.children}
104 </Text>
105 );
106}
107
108function AddProjectShell(props: {
109 readonly title: string;
110 readonly closeToNew?: boolean;
111 readonly children: ReactNode;
112}) {
113 const router = useRouter();
114 const insets = useSafeAreaInsets();
115
116 return (
117 <View className="flex-1 bg-sheet">
118 <NewTaskSheetHeader
119 eyebrow="New project"
120 title={props.title}
121 control={{
122 icon: props.closeToNew ? "xmark" : "chevron.left",
123 onPress: () => router.back(),
124 }}
125 />
126
127 <ScrollView
128 keyboardShouldPersistTaps="handled"
129 contentInsetAdjustmentBehavior="automatic"
130 contentContainerStyle={{
131 paddingHorizontal: 20,
132 paddingTop: 16,
133 paddingBottom: Math.max(insets.bottom, 18) + 18,
134 gap: 10,
135 }}
136 >
137 {props.children}
138 </ScrollView>
139 </View>
140 );
141}
142
143function ListSection(props: { readonly children: ReactNode }) {
144 return <View className="overflow-hidden rounded-[24px] bg-card">{props.children}</View>;
145}
146
147function ListRow(props: {
148 readonly title: string;
149 readonly subtitle?: string | null;
150 readonly icon: ReactNode;
151 readonly disabled?: boolean;
152 readonly selected?: boolean;
153 readonly isFirst?: boolean;
154 readonly right?: ReactNode;
155 readonly onPress?: () => void;
156}) {
157 const borderColor = useThemeColor("--color-border-subtle");
158 const chevronColor = useThemeColor("--color-chevron");
159
160 return (
161 <Pressable
162 disabled={props.disabled}
163 onPress={props.onPress}
164 className="bg-card px-3.5 py-2.5 active:opacity-70"
165 style={{
166 opacity: props.disabled ? 0.45 : 1,
167 borderTopWidth: props.isFirst ? 0 : 1,
168 borderTopColor: borderColor,
169 }}
170 >
171 <View className="flex-row items-center gap-3">
172 <View
173 className={
174 props.selected
175 ? "h-7 w-7 items-center justify-center rounded-full bg-primary"
176 : "h-7 w-7 items-center justify-center"
177 }
178 >
179 {props.icon}
180 </View>
181 <View className="flex-1 gap-0.5">
182 <Text className="text-[15px] font-t3-bold">{props.title}</Text>
183 {props.subtitle ? (
184 <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={2}>
185 {props.subtitle}
186 </Text>
187 ) : null}
188 </View>
189 {"right" in props ? (
190 props.right
191 ) : !props.disabled ? (
192 <SymbolView name="chevron.right" size={13} tintColor={chevronColor} type="monochrome" />
193 ) : null}
194 </View>
195 </Pressable>
196 );
197}
198
199function PrimaryActionButton(props: {
200 readonly label: string;
201 readonly disabled?: boolean;
202 readonly loading?: boolean;
203 readonly onPress: () => void;
204}) {
205 const primaryForeground = useThemeColor("--color-primary-foreground");
206
207 return (
208 <Pressable
209 disabled={props.disabled}
210 onPress={props.onPress}
211 className="h-12 items-center justify-center rounded-full bg-primary active:opacity-70 disabled:opacity-45"
212 >
213 {props.loading ? (
214 <ActivityIndicator color={String(primaryForeground)} />
215 ) : (
216 <Text className="text-[14px] font-t3-bold text-primary-foreground">{props.label}</Text>
217 )}
218 </Pressable>
219 );
220}
221
222function ProjectPathInput(props: {
223 readonly value: string;
224 readonly onChangeText: (value: string) => void;
225 readonly onSubmit: () => void;
226}) {
227 return (
228 <TextInput
229 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-[15px] leading-[20px]"
230 value={props.value}
231 onChangeText={props.onChangeText}
232 autoCapitalize="none"
233 autoCorrect={false}
234 placeholder="~/projects/my-app"
235 returnKeyType="done"
236 onSubmitEditing={props.onSubmit}
237 />
238 );
239}
240
241function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
242 const { serverConfigByEnvironmentId } = useRemoteCatalog();
243 const { savedConnectionsById } = useRemoteEnvironmentState();
244
245 return useMemo<ReadonlyArray<EnvironmentOption>>(() => {
246 const options = Object.values(savedConnectionsById).map((connection) => {
247 const config = serverConfigByEnvironmentId[connection.environmentId];
248 return {
249 environmentId: connection.environmentId,
250 label: connection.environmentLabel,
251 platform: platformFromOs(config?.environment.platform.os ?? null),
252 baseDirectory: config?.settings.addProjectBaseDirectory ?? null,
253 };
254 });
255 return Arr.sort(options, environmentOptionOrder);
256 }, [savedConnectionsById, serverConfigByEnvironmentId]);
257}
258
259function useSelectedEnvironment(): {
260 readonly environmentOptions: ReadonlyArray<EnvironmentOption>;
261 readonly selectedEnvironment: EnvironmentOption | null;
262 readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;
263} {
264 const router = useRouter();
265 const params = useLocalSearchParams<{ environmentId?: string }>();
266 const environmentOptions = useEnvironmentOptions();
267 const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null;
268 const selectedEnvironment =
269 environmentOptions.find(
270 (environment) => environment.environmentId === requestedEnvironmentId,
271 ) ??
272 environmentOptions[0] ??
273 null;
274
275 return {
276 environmentOptions,
277 selectedEnvironment,
278 setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }),
279 };
280}
281
282function EmptyEnvironmentState() {
283 const router = useRouter();
284
285 return (
286 <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">
287 <Text className="text-center text-[17px] font-t3-bold">No environments connected</Text>
288 <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
289 Add an environment before adding a project.
290 </Text>
291 <Pressable
292 onPress={() => router.replace("/connections/new")}
293 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
294 >
295 <Text className="text-[13px] font-t3-bold text-primary-foreground">Add environment</Text>
296 </Pressable>
297 </View>
298 );
299}
300
301function SourceControlRow(props: {
302 readonly source: AddProjectRemoteSource;
303 readonly selectedEnvironmentId: EnvironmentId;
304 readonly ready: boolean;
305 readonly hint: string;
306 readonly isFirst: boolean;
307}) {
308 const router = useRouter();
309 const iconColor = useThemeColor("--color-icon");
310 const title =
311 props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;
312 const subtitle =
313 props.source === "url"
314 ? "Clone from a remote URL"
315 : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`;
316 const icon =
317 props.source === "url" ? (
318 <SymbolView name="link" size={17} tintColor={iconColor} type="monochrome" />
319 ) : (
320 <SourceControlIcon kind={props.source} size={18} color={String(iconColor)} />
321 );
322
323 if (!props.ready) {
324 return (
325 <ListRow title={title} subtitle={props.hint} icon={icon} disabled isFirst={props.isFirst} />
326 );
327 }
328
329 return (
330 <ListRow
331 title={title}
332 subtitle={subtitle}
333 icon={icon}
334 isFirst={props.isFirst}
335 onPress={() =>
336 router.push({
337 pathname: "/new/add-project/repository",
338 params: {
339 environmentId: props.selectedEnvironmentId,
340 source: props.source,
341 },
342 })
343 }
344 />
345 );
346}
347
348export function AddProjectSourceScreen() {
349 const router = useRouter();
350 const accentColor = useThemeColor("--color-icon-muted");
351 const iconColor = useThemeColor("--color-icon");
352 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
353 useSelectedEnvironment();
354 const discoveryState = useSourceControlDiscovery(selectedEnvironment?.environmentId ?? null);
355 const readiness = useMemo(
356 () => buildAddProjectRemoteSourceReadiness(discoveryState.data),
357 [discoveryState.data],
358 );
359
360 useEffect(() => {
361 if (!selectedEnvironment) return;
362 void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId);
363 }, [selectedEnvironment]);
364
365 return (
366 <AddProjectShell title="Select source" closeToNew>
367 {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null}
368
369 {environmentOptions.length > 1 ? (
370 <>
371 <SectionTitle>Connected environments</SectionTitle>
372 <ListSection>
373 {environmentOptions.map((environment, index) => (
374 <ListRow
375 key={environment.environmentId}
376 title={environment.label}
377 subtitle={environment.environmentId}
378 icon={
379 <SymbolView
380 name="server.rack"
381 size={17}
382 tintColor={iconColor}
383 type="monochrome"
384 />
385 }
386 selected={environment.environmentId === selectedEnvironment?.environmentId}
387 isFirst={index === 0}
388 right={
389 environment.environmentId === selectedEnvironment?.environmentId ? (
390 <SymbolView
391 name="checkmark"
392 size={14}
393 tintColor={iconColor}
394 type="monochrome"
395 />
396 ) : null
397 }
398 onPress={() => setSelectedEnvironmentId(environment.environmentId)}
399 />
400 ))}
401 </ListSection>
402 </>
403 ) : null}
404
405 {selectedEnvironment ? (
406 <>
407 <ListSection>
408 <ListRow
409 title="Local folder"
410 subtitle="Browse a folder on disk"
411 icon={
412 <SymbolView
413 name="folder.badge.plus"
414 size={17}
415 tintColor={iconColor}
416 type="monochrome"
417 />
418 }
419 isFirst
420 onPress={() =>
421 router.push({
422 pathname: "/new/add-project/local",
423 params: { environmentId: selectedEnvironment.environmentId },
424 })
425 }
426 />
427 {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map(
428 (candidate) => (
429 <SourceControlRow
430 key={candidate}
431 source={candidate}
432 selectedEnvironmentId={selectedEnvironment.environmentId}
433 ready={readiness[candidate].ready}
434 hint={
435 readiness[candidate].ready
436 ? addProjectRemoteSourcePathHint(candidate)
437 : (readiness[candidate].hint ?? "")
438 }
439 isFirst={false}
440 />
441 ),
442 )}
443 </ListSection>
444 {discoveryState.isPending ? <ActivityIndicator color={accentColor} /> : null}
445 </>
446 ) : null}
447 </AddProjectShell>
448 );
449}
450
451function useCreateProject(environment: EnvironmentOption | null) {
452 const router = useRouter();
453 const { projects } = useRemoteCatalog();
454
455 return useCallback(
456 async (workspaceRoot: string) => {
457 if (!environment) return;
458 const client = getEnvironmentClient(environment.environmentId);
459 if (!client) throw new Error("Environment API is not available.");
460
461 const existing = findExistingAddProject({
462 projects,
463 environmentId: environment.environmentId,
464 path: workspaceRoot,
465 });
466 if (existing) {
467 Alert.alert("Project already exists", existing.title);
468 router.replace({
469 pathname: "/new/draft",
470 params: {
471 environmentId: existing.environmentId,
472 projectId: existing.id,
473 title: existing.title,
474 },
475 });
476 return;
477 }
478
479 const projectId = ProjectId.make(uuidv4());
480 await client.orchestration.dispatchCommand(
481 buildProjectCreateCommand({
482 commandId: CommandId.make(uuidv4()),
483 projectId,
484 workspaceRoot,
485 createdAt: new Date().toISOString(),
486 }),
487 );
488 router.replace({
489 pathname: "/new/draft",
490 params: {
491 environmentId: environment.environmentId,
492 projectId,
493 title: inferProjectTitleFromPath(workspaceRoot),
494 },
495 });
496 },
497 [environment, projects, router],
498 );
499}
500
501function useEnvironmentFromParam(): EnvironmentOption | null {
502 const params = useLocalSearchParams<{ environmentId?: string }>();
503 const environmentOptions = useEnvironmentOptions();
504 const environmentId = stringParam(params.environmentId) as EnvironmentId | null;
505 return (
506 environmentOptions.find((environment) => environment.environmentId === environmentId) ??
507 environmentOptions[0] ??
508 null
509 );
510}
511
512export function AddProjectRepositoryScreen() {
513 const router = useRouter();
514 const params = useLocalSearchParams<{ environmentId?: string; source?: string }>();
515 const environment = useEnvironmentFromParam();
516 const source = sourceFromParam(params.source);
517 const [repositoryInput, setRepositoryInput] = useState("");
518 const [isSubmitting, setIsSubmitting] = useState(false);
519 const [error, setError] = useState<string | null>(null);
520
521 const lookupRepository = useCallback(async () => {
522 if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return;
523 setError(null);
524 setIsSubmitting(true);
525 try {
526 const provider = addProjectRemoteSourceProvider(source);
527 if (!provider) {
528 const remoteUrl = repositoryInput.trim();
529 router.push({
530 pathname: "/new/add-project/destination",
531 params: {
532 environmentId: environment.environmentId,
533 source,
534 remoteUrl,
535 repositoryTitle: remoteUrl,
536 },
537 });
538 return;
539 }
540
541 const client = getEnvironmentClient(environment.environmentId);
542 if (!client) throw new Error("Environment API is not available.");
543 const repository = await client.sourceControl.lookupRepository({
544 provider,
545 repository: repositoryInput.trim(),
546 });
547 router.push({
548 pathname: "/new/add-project/destination",
549 params: {
550 environmentId: environment.environmentId,
551 source,
552 remoteUrl: repository.sshUrl,
553 repositoryTitle: repository.nameWithOwner,
554 },
555 });
556 } catch (nextError) {
557 setError(errorMessage(nextError));
558 } finally {
559 setIsSubmitting(false);
560 }
561 }, [environment, isSubmitting, repositoryInput, router, source]);
562
563 return (
564 <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}>
565 {error ? <ErrorBanner message={error} /> : null}
566 <TextInput
567 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-[15px] leading-[20px]"
568 value={repositoryInput}
569 onChangeText={setRepositoryInput}
570 autoCapitalize="none"
571 autoCorrect={false}
572 placeholder={
573 source === "url"
574 ? "https://github.com/org/repo.git"
575 : addProjectRemoteSourcePathHint(source)
576 }
577 returnKeyType="next"
578 onSubmitEditing={() => void lookupRepository()}
579 />
580 <PrimaryActionButton
581 label={source === "url" ? "Continue" : "Lookup repository"}
582 disabled={isSubmitting || repositoryInput.trim().length === 0}
583 onPress={() => void lookupRepository()}
584 loading={isSubmitting}
585 />
586 </AddProjectShell>
587 );
588}
589
590function FolderBrowser(props: {
591 readonly environment: EnvironmentOption;
592 readonly pathInput: string;
593 readonly setPathInput: (path: string) => void;
594}) {
595 const accentColor = useThemeColor("--color-icon-muted");
596 const browseDirectoryPath = useMemo(
597 () =>
598 isFilesystemBrowseQuery(props.pathInput, props.environment.platform)
599 ? getBrowseDirectoryPath(props.pathInput)
600 : "",
601 [props.environment.platform, props.pathInput],
602 );
603 const browseFilterQuery =
604 browseDirectoryPath.length > 0 && !hasTrailingPathSeparator(props.pathInput)
605 ? getBrowseLeafPathSegment(props.pathInput).toLowerCase()
606 : "";
607 const browseInput = useMemo(
608 () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null),
609 [browseDirectoryPath],
610 );
611 const browseState = useFilesystemBrowse(props.environment.environmentId, browseInput);
612 const visibleBrowseEntries = useMemo(
613 () =>
614 Arr.sort(
615 Arr.filter(
616 browseState.data?.entries ?? [],
617 (entry) =>
618 !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(browseFilterQuery),
619 ),
620 browseEntryOrder,
621 ),
622 [browseFilterQuery, browseState.data?.entries],
623 );
624 const parentBrowsePath = getBrowseParentPath(browseDirectoryPath);
625 const canBrowseUpPath = canNavigateUp(browseDirectoryPath);
626
627 return (
628 <>
629 <SectionTitle>Browse folders</SectionTitle>
630 {browseState.error ? <ErrorBanner message={browseState.error} /> : null}
631 <ListSection>
632 {browseState.isPending && browseState.data === null ? (
633 <View className="items-center py-5">
634 <ActivityIndicator color={accentColor} />
635 </View>
636 ) : null}
637 {canBrowseUpPath ? (
638 <ListRow
639 title=".."
640 icon={
641 <SymbolView
642 name="arrow.turn.left.up"
643 size={17}
644 tintColor={accentColor}
645 type="monochrome"
646 />
647 }
648 isFirst
649 right={null}
650 onPress={() => {
651 if (parentBrowsePath) props.setPathInput(parentBrowsePath);
652 }}
653 />
654 ) : null}
655 {visibleBrowseEntries.map((entry, index) => (
656 <ListRow
657 key={entry.fullPath}
658 title={entry.name}
659 icon={<SymbolView name="folder" size={17} tintColor={accentColor} type="monochrome" />}
660 isFirst={index === 0 && !canBrowseUpPath}
661 right={null}
662 onPress={() =>
663 props.setPathInput(
664 browseDirectoryPath.length > 0
665 ? appendBrowsePathSegment(browseDirectoryPath, entry.name)
666 : ensureBrowseDirectoryPath(entry.fullPath),
667 )
668 }
669 />
670 ))}
671 </ListSection>
672 </>
673 );
674}
675
676export function AddProjectLocalFolderScreen() {
677 const environment = useEnvironmentFromParam();
678 const createProject = useCreateProject(environment);
679 const [pathInput, setPathInput] = useState(() =>
680 getAddProjectInitialQuery(environment?.baseDirectory),
681 );
682 const [isSubmitting, setIsSubmitting] = useState(false);
683 const [error, setError] = useState<string | null>(null);
684
685 useEffect(() => {
686 if (!environment) return;
687 setPathInput(getAddProjectInitialQuery(environment.baseDirectory));
688 }, [environment]);
689
690 const submitPath = useCallback(async () => {
691 if (!environment || isSubmitting) return;
692 setError(null);
693 const resolved = resolveAddProjectPath({
694 rawPath: pathInput,
695 currentProjectCwd: null,
696 platform: environment.platform,
697 });
698 if (!resolved.ok) {
699 setError(resolved.error);
700 return;
701 }
702
703 setIsSubmitting(true);
704 try {
705 await createProject(resolved.path);
706 } catch (nextError) {
707 setError(errorMessage(nextError));
708 } finally {
709 setIsSubmitting(false);
710 }
711 }, [createProject, environment, isSubmitting, pathInput]);
712
713 return (
714 <AddProjectShell title="Local folder">
715 {error ? <ErrorBanner message={error} /> : null}
716 {environment ? (
717 <>
718 <ProjectPathInput
719 value={pathInput}
720 onChangeText={setPathInput}
721 onSubmit={() => void submitPath()}
722 />
723 <PrimaryActionButton
724 label="Add project"
725 disabled={isSubmitting}
726 onPress={() => void submitPath()}
727 loading={isSubmitting}
728 />
729 <FolderBrowser
730 environment={environment}
731 pathInput={pathInput}
732 setPathInput={setPathInput}
733 />
734 </>
735 ) : (
736 <EmptyEnvironmentState />
737 )}
738 </AddProjectShell>
739 );
740}
741
742export function AddProjectDestinationScreen() {
743 const params = useLocalSearchParams<{
744 environmentId?: string;
745 remoteUrl?: string;
746 repositoryTitle?: string;
747 }>();
748 const environment = useEnvironmentFromParam();
749 const createProject = useCreateProject(environment);
750 const remoteUrl = stringParam(params.remoteUrl);
751 const repositoryTitle = stringParam(params.repositoryTitle);
752 const [pathInput, setPathInput] = useState(() =>
753 getAddProjectInitialQuery(environment?.baseDirectory),
754 );
755 const [isSubmitting, setIsSubmitting] = useState(false);
756 const [error, setError] = useState<string | null>(null);
757
758 useEffect(() => {
759 if (!environment) return;
760 setPathInput(getAddProjectInitialQuery(environment.baseDirectory));
761 }, [environment]);
762
763 const submitPath = useCallback(async () => {
764 if (!environment || !remoteUrl || isSubmitting) return;
765 setError(null);
766 const resolved = resolveAddProjectPath({
767 rawPath: pathInput,
768 currentProjectCwd: null,
769 platform: environment.platform,
770 });
771 if (!resolved.ok) {
772 setError(resolved.error);
773 return;
774 }
775
776 setIsSubmitting(true);
777 try {
778 const client = getEnvironmentClient(environment.environmentId);
779 if (!client) throw new Error("Environment API is not available.");
780 const result = await client.sourceControl.cloneRepository({
781 remoteUrl,
782 destinationPath: resolved.path,
783 });
784 await createProject(result.cwd);
785 } catch (nextError) {
786 setError(errorMessage(nextError));
787 } finally {
788 setIsSubmitting(false);
789 }
790 }, [createProject, environment, isSubmitting, pathInput, remoteUrl]);
791
792 return (
793 <AddProjectShell title="Clone destination">
794 {error ? <ErrorBanner message={error} /> : null}
795 {repositoryTitle ? (
796 <View className="rounded-[24px] bg-card px-4 py-3">
797 <Text className="text-[14px] font-t3-bold">{repositoryTitle}</Text>
798 <Text className="mt-0.5 text-[12px] text-foreground-muted" numberOfLines={2}>
799 {remoteUrl}
800 </Text>
801 </View>
802 ) : null}
803 {environment ? (
804 <>
805 <ProjectPathInput
806 value={pathInput}
807 onChangeText={setPathInput}
808 onSubmit={() => void submitPath()}
809 />
810 <PrimaryActionButton
811 label="Clone project"
812 disabled={isSubmitting || !remoteUrl}
813 onPress={() => void submitPath()}
814 loading={isSubmitting}
815 />
816 <FolderBrowser
817 environment={environment}
818 pathInput={pathInput}
819 setPathInput={setPathInput}
820 />
821 </>
822 ) : (
823 <EmptyEnvironmentState />
824 )}
825 </AddProjectShell>
826 );
827}
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx created+318
...@@ -0,0 +1,318 @@
1import { useLocalSearchParams, useRouter } from "expo-router";
2import { SymbolView } from "expo-symbols";
3import { TextInputWrapper } from "expo-paste-input";
4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
5import { useCallback, useEffect, useMemo, useState } from "react";
6import { Pressable, ScrollView, View, useColorScheme, useWindowDimensions } from "react-native";
7import { KeyboardAvoidingView } from "react-native-keyboard-controller";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9import ImageViewing from "react-native-image-viewing";
10
11import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
12import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
13import { ControlPill } from "../../components/ControlPill";
14import { cn } from "../../lib/cn";
15import type { DraftComposerImageAttachment } from "../../lib/composerImages";
16import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
17import { useThemeColor } from "../../lib/useThemeColor";
18import { useNativePaste } from "../../lib/useNativePaste";
19import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
20import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state";
21import {
22 clearReviewCommentTarget,
23 formatReviewCommentContext,
24 getReviewUnifiedLineNumber,
25 getSelectedReviewCommentLines,
26 useReviewCommentTarget,
27} from "./reviewCommentSelection";
28import {
29 changeTone,
30 DiffTokenText,
31 REVIEW_DIFF_LINE_HEIGHT,
32 REVIEW_MONO_FONT_FAMILY,
33 ReviewChangeBar,
34} from "./reviewDiffRendering";
35import {
36 highlightReviewSelectedLines,
37 type ReviewDiffTheme,
38 type ReviewHighlightedToken,
39} from "./shikiReviewHighlighter";
40
41const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
42
43export function ReviewCommentComposerSheet() {
44 const router = useRouter();
45 const insets = useSafeAreaInsets();
46 const { width } = useWindowDimensions();
47 const colorScheme = useColorScheme();
48 const iconTint = String(useThemeColor("--color-icon"));
49 const target = useReviewCommentTarget();
50 const { environmentId, threadId } = useLocalSearchParams<{
51 environmentId: EnvironmentId;
52 threadId: ThreadId;
53 }>();
54 const [commentText, setCommentText] = useState("");
55 const [highlightedLinesById, setHighlightedLinesById] = useState<
56 Record<string, ReadonlyArray<ReviewHighlightedToken>>
57 >({});
58 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
59 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
60
61 const selectedLines = useMemo(
62 () => (target ? getSelectedReviewCommentLines(target) : []),
63 [target],
64 );
65 const firstLine = selectedLines[0] ?? null;
66 const lastLine = selectedLines[selectedLines.length - 1] ?? null;
67 const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
68 const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
69 const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
70 const canSubmit =
71 commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;
72 const selectionLabel =
73 selectedLines.length === 1
74 ? firstNumber !== null
75 ? `Line ${firstNumber}`
76 : "File comment"
77 : firstNumber !== null && lastNumber !== null
78 ? `Lines ${firstNumber}-${lastNumber}`
79 : `${selectedLines.length} lines selected`;
80 const previewHeight = Math.max(
81 Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT,
82 REVIEW_DIFF_LINE_HEIGHT,
83 );
84 const previewViewportWidth = Math.max(width - 40, 280);
85 const dismissComposer = useCallback(() => {
86 clearReviewCommentTarget();
87 router.dismiss();
88 }, [router]);
89 const handleNativePaste = useNativePaste((uris) => {
90 void (async () => {
91 try {
92 const images = await convertPastedImagesToAttachments({
93 uris,
94 existingCount: attachments.length,
95 });
96 if (images.length > 0) {
97 setAttachments((current) => [...current, ...images]);
98 }
99 } catch (error) {
100 console.error("[review comment] error converting pasted images", error);
101 }
102 })();
103 });
104
105 useEffect(() => {
106 if (!target || selectedLines.length === 0) {
107 setHighlightedLinesById({});
108 return;
109 }
110
111 let cancelled = false;
112 void highlightReviewSelectedLines({
113 filePath: target.filePath,
114 lines: selectedLines,
115 theme: selectedTheme,
116 })
117 .then((next) => {
118 if (!cancelled) {
119 setHighlightedLinesById(next);
120 }
121 })
122 .catch(() => {
123 if (!cancelled) {
124 setHighlightedLinesById({});
125 }
126 });
127
128 return () => {
129 cancelled = true;
130 };
131 }, [selectedLines, selectedTheme, target]);
132
133 async function handlePickImages(): Promise<void> {
134 const result = await pickComposerImages({ existingCount: attachments.length });
135 if (result.images.length > 0) {
136 setAttachments((current) => [...current, ...result.images]);
137 }
138 if (result.error) {
139 setPendingConnectionError(result.error);
140 }
141 }
142
143 return (
144 <View style={{ flex: 1 }}>
145 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
146 <View
147 style={{
148 flex: 1,
149 paddingHorizontal: 20,
150 paddingTop: 8,
151 paddingBottom: target ? 0 : Math.max(insets.bottom, 18),
152 }}
153 >
154 <View className="flex-row items-center justify-between py-2">
155 <Pressable
156 className="bg-subtle h-12 w-12 items-center justify-center rounded-full"
157 onPress={dismissComposer}
158 >
159 <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" />
160 </Pressable>
161
162 <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text>
163
164 <View className="h-12 w-12" />
165 </View>
166
167 {!target ? (
168 <View className="rounded-[22px] border border-border bg-card px-4 py-5">
169 <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text>
170 <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted">
171 Select a diff line or range first.
172 </Text>
173 </View>
174 ) : (
175 <View className="min-h-0 flex-1 gap-4">
176 <View className="gap-1 px-1">
177 <Text className="text-[11px] font-t3-bold uppercase text-foreground-muted">
178 {selectionLabel}
179 </Text>
180 <Text
181 className="font-mono text-[12px] leading-[17px] text-foreground-muted"
182 ellipsizeMode="middle"
183 numberOfLines={2}
184 >
185 {target.filePath}
186 </Text>
187 </View>
188
189 <View className="overflow-hidden rounded-[22px] border border-border bg-card">
190 <ScrollView
191 horizontal
192 bounces={false}
193 keyboardShouldPersistTaps="always"
194 showsHorizontalScrollIndicator={false}
195 >
196 <ScrollView
197 bounces={false}
198 scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES}
199 nestedScrollEnabled
200 keyboardShouldPersistTaps="always"
201 showsVerticalScrollIndicator={
202 selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES
203 }
204 style={{ height: previewHeight }}
205 >
206 <View style={{ minWidth: previewViewportWidth }}>
207 {selectedLines.map((line) => {
208 const lineNumber = getReviewUnifiedLineNumber(line);
209
210 return (
211 <View
212 key={line.id}
213 className={cn("flex-row items-start", changeTone(line.change))}
214 style={{ height: REVIEW_DIFF_LINE_HEIGHT }}
215 >
216 <ReviewChangeBar change={line.change} />
217 <Text
218 className="w-9 py-1 pr-1 text-right text-[11px] font-t3-medium text-foreground-muted"
219 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
220 >
221 {lineNumber ?? ""}
222 </Text>
223 <View className="min-w-0 flex-1 shrink-0 px-1 py-1">
224 <DiffTokenText
225 fallback={line.content}
226 tokens={highlightedLinesById[line.id] ?? null}
227 change={line.change}
228 />
229 </View>
230 </View>
231 );
232 })}
233 </View>
234 </ScrollView>
235 </ScrollView>
236 </View>
237
238 <View className="min-h-0 flex-1 gap-2">
239 <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text>
240 <View className="min-h-[132px] flex-1 overflow-hidden rounded-[20px] border border-border bg-card">
241 <View className="flex-1 px-4 pt-3.5">
242 <TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1 }}>
243 <TextInput
244 autoFocus
245 multiline
246 placeholder="Leave a comment..."
247 textAlignVertical="top"
248 value={commentText}
249 onChangeText={setCommentText}
250 className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-[15px]"
251 style={{ flex: 1 }}
252 />
253 </TextInputWrapper>
254 </View>
255 {attachments.length > 0 ? (
256 <View className="px-4 pb-3 pt-2">
257 <ComposerAttachmentStrip
258 attachments={attachments}
259 imageBorderRadius={16}
260 imageSize={60}
261 onPressImage={setPreviewImageUri}
262 removeButtonPlacement="gutter"
263 onRemove={(imageId) => {
264 setAttachments((current) =>
265 current.filter((image) => image.id !== imageId),
266 );
267 }}
268 />
269 </View>
270 ) : null}
271 </View>
272 </View>
273 </View>
274 )}
275 </View>
276 {target ? (
277 <View className="flex-row items-center gap-3 bg-sheet px-5 py-2">
278 <ControlPill
279 accessibilityLabel="Add image"
280 icon="plus"
281 onPress={() => void handlePickImages()}
282 />
283 <View className="flex-1" />
284 <ControlPill
285 accessibilityLabel="Comment"
286 icon="arrow.up"
287 label="Comment"
288 variant="primary"
289 disabled={!canSubmit}
290 onPress={() => {
291 if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
292 return;
293 }
294
295 appendReviewCommentToDraft({
296 environmentId,
297 threadId,
298 text: formatReviewCommentContext(target, commentText),
299 attachments,
300 });
301 setAttachments([]);
302 dismissComposer();
303 }}
304 />
305 </View>
306 ) : null}
307 </KeyboardAvoidingView>
308 <ImageViewing
309 images={previewImageUri ? [{ uri: previewImageUri }] : []}
310 imageIndex={0}
311 visible={previewImageUri !== null}
312 onRequestClose={() => setPreviewImageUri(null)}
313 swipeToCloseEnabled
314 doubleTapToZoomEnabled
315 />
316 </View>
317 );
318}
apps/mobile/src/features/review/ReviewHighlighterProvider.tsx created+24
...@@ -0,0 +1,24 @@
1import { createContext, type ReactNode, useContext, useMemo } from "react";
2
3import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState";
4
5const ReviewHighlighterContext = createContext<ReviewHighlighterState>({
6 engine: null,
7 error: null,
8 status: "idle",
9});
10
11export function ReviewHighlighterProvider(props: { readonly children: ReactNode }) {
12 const value = useReviewHighlighterState();
13 const contextValue = useMemo(() => value, [value]);
14
15 return (
16 <ReviewHighlighterContext.Provider value={contextValue}>
17 {props.children}
18 </ReviewHighlighterContext.Provider>
19 );
20}
21
22export function useReviewHighlighterStatus(): ReviewHighlighterState {
23 return useContext(ReviewHighlighterContext);
24}
apps/mobile/src/features/review/ReviewSheet.tsx created+432
...@@ -0,0 +1,432 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useLocalSearchParams } from "expo-router";
3import Stack from "expo-router/stack";
4import { SymbolView } from "expo-symbols";
5import { memo, type ReactElement, useCallback, useMemo } from "react";
6import {
7 ActivityIndicator,
8 Pressable,
9 ScrollView,
10 type NativeSyntheticEvent,
11 Text as NativeText,
12 StyleSheet,
13 useColorScheme,
14 View,
15} from "react-native";
16import { useSafeAreaInsets } from "react-native-safe-area-context";
17
18import { AppText as Text } from "../../components/AppText";
19import { useThemeColor } from "../../lib/useThemeColor";
20import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
21import { useReviewCacheForThread } from "./reviewState";
22import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
23import {
24 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
25 NATIVE_REVIEW_DIFF_ROW_HEIGHT,
26} from "./nativeReviewDiffAdapter";
27import { useReviewDiffData } from "./useReviewDiffData";
28import { useReviewFileVisibility } from "./reviewFileVisibility";
29import { useReviewSections } from "./useReviewSections";
30import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";
31import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";
32
33const IOS_NAV_BAR_HEIGHT = 44;
34const REVIEW_HEADER_SPACING = 0;
35
36const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
37 return (
38 <View className="border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-900/60 dark:bg-amber-950/40">
39 <Text className="text-[12px] font-t3-bold uppercase text-amber-700 dark:text-amber-300">
40 Partial diff
41 </Text>
42 <Text className="text-[12px] leading-[18px] text-amber-800 dark:text-amber-200">
43 {props.notice}
44 </Text>
45 </View>
46 );
47});
48
49function ReviewSelectionActionBar(props: {
50 readonly bottomInset: number;
51 readonly title: string | null;
52 readonly onOpenComment: (() => void) | null;
53 readonly onClear: () => void;
54}) {
55 if (!props.title) {
56 return null;
57 }
58
59 const content = (
60 <>
61 <SymbolView
62 name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"}
63 size={16}
64 tintColor="#ffffff"
65 type="monochrome"
66 />
67 <Text className="text-[15px] font-t3-bold text-white">{props.title}</Text>
68 </>
69 );
70
71 return (
72 <View
73 pointerEvents="box-none"
74 style={{
75 position: "absolute",
76 left: 18,
77 right: 18,
78 bottom: Math.max(props.bottomInset, 10) + 18,
79 flexDirection: "row",
80 justifyContent: "center",
81 gap: 10,
82 }}
83 >
84 {props.onOpenComment ? (
85 <Pressable
86 className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-blue-600 px-5"
87 onPress={props.onOpenComment}
88 >
89 {content}
90 </Pressable>
91 ) : (
92 <View className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-blue-600 px-5">
93 {content}
94 </View>
95 )}
96
97 <Pressable
98 className="h-12 w-12 items-center justify-center rounded-full bg-blue-600"
99 onPress={props.onClear}
100 >
101 <SymbolView name="xmark" size={16} tintColor="#ffffff" type="monochrome" />
102 </Pressable>
103 </View>
104 );
105}
106
107export function ReviewSheet() {
108 const insets = useSafeAreaInsets();
109 const colorScheme = useColorScheme();
110 const headerForeground = String(useThemeColor("--color-foreground"));
111 const headerMuted = String(useThemeColor("--color-foreground-muted"));
112 const headerIcon = String(useThemeColor("--color-icon"));
113 const { environmentId, threadId } = useLocalSearchParams<{
114 environmentId: EnvironmentId;
115 threadId: ThreadId;
116 }>();
117 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
118 const reviewCache = useReviewCacheForThread({ environmentId, threadId });
119 const selectedTheme = colorScheme === "dark" ? "dark" : "light";
120 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
121 const {
122 error,
123 loadingGitDiffs,
124 loadingTurnIds,
125 reviewSections,
126 selectedSection,
127 refreshSelectedSection,
128 selectSection,
129 } = useReviewSections({ environmentId, threadId, reviewCache });
130 const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =
131 useReviewDiffData({
132 threadKey: reviewCache.threadKey,
133 selectedSection,
134 draftMessage,
135 });
136 const NativeReviewDiffView = resolveNativeReviewDiffView()!;
137 const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];
138 const fileVisibility = useReviewFileVisibility({
139 threadKey: reviewCache.threadKey,
140 sectionId: selectedSection?.id ?? null,
141 files: reviewFiles,
142 cachedExpandedFileIds: selectedSection?.id
143 ? reviewCache.expandedFileIdsBySection[selectedSection.id]
144 : undefined,
145 cachedViewedFileIds: selectedSection?.id
146 ? reviewCache.viewedFileIdsBySection[selectedSection.id]
147 : undefined,
148 });
149 const { collapsedFileIds, toggleExpandedFile, toggleViewedFile, viewedFileIds } = fileVisibility;
150 const commentSelection = useReviewCommentSelectionController({
151 environmentId,
152 threadId,
153 selectedSection,
154 nativeReviewDiffData,
155 });
156 const nativeBridge = useNativeReviewDiffBridge({
157 threadKey: reviewCache.threadKey,
158 sectionId: selectedSection?.id ?? null,
159 diff: selectedSection?.diff,
160 data: nativeReviewDiffData,
161 scheme: selectedTheme,
162 collapsedFileIds,
163 viewedFileIds,
164 selectedRowIds: commentSelection.selectedRowIds,
165 canHighlight: parsedDiff.kind === "files",
166 });
167
168 const handleNativeToggleFile = useCallback(
169 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
170 const { fileId } = event.nativeEvent;
171 if (fileId) {
172 toggleExpandedFile(fileId);
173 }
174 },
175 [toggleExpandedFile],
176 );
177
178 const handleNativeToggleViewedFile = useCallback(
179 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
180 const { fileId } = event.nativeEvent;
181 if (fileId) {
182 toggleViewedFile(fileId);
183 }
184 },
185 [toggleViewedFile],
186 );
187
188 const parsedDiffNotice =
189 parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
190
191 const listHeader = useMemo(() => {
192 const children: ReactElement[] = [];
193
194 if (error) {
195 children.push(
196 <View key="review-error" className="border-b border-border bg-card px-4 py-3">
197 <Text className="text-[13px] font-t3-bold text-foreground">Review unavailable</Text>
198 <Text className="text-[12px] leading-[18px] text-foreground-muted">{error}</Text>
199 </View>,
200 );
201 }
202
203 if (parsedDiffNotice) {
204 children.push(<ReviewNotice key="review-notice" notice={parsedDiffNotice} />);
205 }
206
207 if (children.length === 0) {
208 return null;
209 }
210
211 return <>{children}</>;
212 }, [error, parsedDiffNotice]);
213
214 return (
215 <>
216 <Stack.Screen
217 options={{
218 headerTransparent: true,
219 headerShadowVisible: false,
220 headerTintColor: headerIcon,
221 headerStyle: {
222 backgroundColor: "transparent",
223 },
224 headerTitle: () => (
225 <View style={{ alignItems: "center" }}>
226 <NativeText
227 numberOfLines={1}
228 style={{
229 fontFamily: "DMSans_700Bold",
230 fontSize: 18,
231 fontWeight: "900",
232 color: headerForeground,
233 letterSpacing: -0.4,
234 }}
235 >
236 Files Changed
237 </NativeText>
238 <View
239 style={{
240 flexDirection: "row",
241 alignItems: "center",
242 justifyContent: "center",
243 gap: 6,
244 flexWrap: "wrap",
245 }}
246 >
247 {headerDiffSummary.additions && headerDiffSummary.deletions ? (
248 <>
249 <NativeText
250 style={{
251 fontFamily: "DMSans_700Bold",
252 fontSize: 12,
253 fontWeight: "700",
254 color: "#16a34a",
255 }}
256 >
257 {headerDiffSummary.additions}
258 </NativeText>
259 <NativeText
260 style={{
261 fontFamily: "DMSans_700Bold",
262 fontSize: 12,
263 fontWeight: "700",
264 color: "#e11d48",
265 }}
266 >
267 {headerDiffSummary.deletions}
268 </NativeText>
269 {pendingReviewCommentCount > 0 ? (
270 <NativeText
271 style={{
272 fontFamily: "DMSans_700Bold",
273 fontSize: 12,
274 fontWeight: "700",
275 color: "#b45309",
276 }}
277 >
278 {pendingReviewCommentCount} pending
279 </NativeText>
280 ) : null}
281 </>
282 ) : (
283 <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
284 <NativeText
285 numberOfLines={1}
286 style={{
287 fontFamily: "DMSans_700Bold",
288 fontSize: 12,
289 fontWeight: "700",
290 color: headerMuted,
291 }}
292 >
293 {selectedSection?.title ?? "Review changes"}
294 </NativeText>
295 {pendingReviewCommentCount > 0 ? (
296 <NativeText
297 style={{
298 fontFamily: "DMSans_700Bold",
299 fontSize: 12,
300 fontWeight: "700",
301 color: "#b45309",
302 }}
303 >
304 {pendingReviewCommentCount} pending
305 </NativeText>
306 ) : null}
307 </View>
308 )}
309 </View>
310 </View>
311 ),
312 }}
313 />
314
315 <Stack.Toolbar placement="right">
316 <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground>
317 {reviewSections.map((section) => (
318 <Stack.Toolbar.MenuAction
319 key={section.id}
320 icon={section.id === selectedSection?.id ? "checkmark" : "circle"}
321 onPress={() => selectSection(section.id)}
322 subtitle={section.subtitle ?? undefined}
323 >
324 <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label>
325 </Stack.Toolbar.MenuAction>
326 ))}
327 <Stack.Toolbar.MenuAction
328 icon="arrow.clockwise"
329 disabled={
330 loadingGitDiffs ||
331 (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true)
332 }
333 onPress={() => void refreshSelectedSection()}
334 subtitle="Reload current diff"
335 >
336 <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label>
337 </Stack.Toolbar.MenuAction>
338 </Stack.Toolbar.Menu>
339 </Stack.Toolbar>
340
341 <View className="flex-1 bg-sheet">
342 {selectedSection && parsedDiff.kind === "files" ? (
343 <View
344 className="flex-1"
345 style={{
346 backgroundColor: nativeBridge.theme.background,
347 paddingTop: topContentInset + REVIEW_HEADER_SPACING,
348 }}
349 >
350 {listHeader}
351 <View className="flex-1" collapsable={false}>
352 <NativeReviewDiffView
353 key={`${reviewCache.threadKey}:${selectedSection.id}`}
354 collapsable={false}
355 testID="review-native-diff-view"
356 style={StyleSheet.absoluteFill}
357 appearanceScheme={selectedTheme}
358 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
359 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
360 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
361 rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}
362 rowsJson={nativeBridge.rowsJson}
363 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
364 styleJson={nativeBridge.styleJson}
365 themeJson={nativeBridge.themeJson}
366 tokensPatchJson={nativeBridge.tokensPatchJson}
367 tokensResetKey={nativeBridge.tokensResetKey}
368 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
369 onDebug={nativeBridge.onDebug}
370 onPressLine={commentSelection.onPressLine}
371 onToggleComment={nativeBridge.onToggleComment}
372 onToggleFile={handleNativeToggleFile}
373 onToggleViewedFile={handleNativeToggleViewedFile}
374 />
375 </View>
376 </View>
377 ) : (
378 <ScrollView
379 contentInsetAdjustmentBehavior="never"
380 contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }}
381 contentOffset={{ x: 0, y: -topContentInset }}
382 scrollIndicatorInsets={{
383 top: topContentInset,
384 bottom: Math.max(insets.bottom, 18) + 18,
385 }}
386 showsVerticalScrollIndicator={false}
387 style={{ flex: 1 }}
388 >
389 {listHeader}
390 {!selectedSection ? (
391 <View className="border-b border-border bg-card px-4 py-5">
392 <Text className="text-[14px] font-t3-bold text-foreground">No review diffs</Text>
393 <Text className="text-[12px] leading-[18px] text-foreground-muted">
394 This thread has no ready turn diffs and the worktree diff is empty.
395 </Text>
396 </View>
397 ) : selectedSection.isLoading && selectedSection.diff === null ? (
398 <View className="items-center gap-3 border-b border-border bg-card px-4 py-6">
399 <ActivityIndicator size="small" />
400 <Text className="text-[12px] text-foreground-muted">Loading diff…</Text>
401 </View>
402 ) : parsedDiff.kind === "empty" ? (
403 <View className="border-b border-border bg-card px-4 py-5">
404 <Text className="text-[14px] font-t3-bold text-foreground">No changes</Text>
405 <Text className="text-[12px] leading-[18px] text-foreground-muted">
406 {selectedSection.subtitle ?? "This diff is empty."}
407 </Text>
408 </View>
409 ) : parsedDiff.kind === "raw" ? (
410 <View className="gap-3 border-b border-border bg-card px-4 py-4">
411 <Text className="text-[12px] leading-[18px] text-foreground-muted">
412 {parsedDiff.reason}
413 </Text>
414 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
415 <Text selectable className="font-mono text-[12px] leading-[19px] text-foreground">
416 {parsedDiff.text}
417 </Text>
418 </ScrollView>
419 </View>
420 ) : null}
421 </ScrollView>
422 )}
423 <ReviewSelectionActionBar
424 bottomInset={insets.bottom}
425 title={commentSelection.selectionAction?.title ?? null}
426 onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null}
427 onClear={commentSelection.clearSelection}
428 />
429 </View>
430 </>
431 );
432}
apps/mobile/src/features/review/diffParser.ts created+158
...@@ -0,0 +1,158 @@
1export type ParsedDiffLineType = "context" | "add" | "delete" | "meta" | "hunk";
2
3export interface ParsedDiffLine {
4 readonly id: string;
5 readonly type: ParsedDiffLineType;
6 readonly oldLine: number | null;
7 readonly newLine: number | null;
8 readonly content: string;
9}
10
11export interface ParsedDiffFile {
12 readonly id: string;
13 readonly oldPath: string | null;
14 readonly newPath: string | null;
15 readonly lines: ReadonlyArray<ParsedDiffLine>;
16}
17
18function parseHunkStart(
19 line: string,
20): { readonly oldLine: number; readonly newLine: number } | null {
21 const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
22 if (!match) {
23 return null;
24 }
25
26 return {
27 oldLine: Number.parseInt(match[1] ?? "0", 10),
28 newLine: Number.parseInt(match[2] ?? "0", 10),
29 };
30}
31
32function parseDiffPath(line: string, prefix: "--- " | "+++ "): string | null {
33 if (!line.startsWith(prefix)) {
34 return null;
35 }
36 const raw = line.slice(prefix.length).trim();
37 if (raw === "/dev/null") {
38 return null;
39 }
40 return raw.replace(/^[ab]\//, "");
41}
42
43export function parseUnifiedDiff(diff: string): ReadonlyArray<ParsedDiffFile> {
44 const files: ParsedDiffFile[] = [];
45 let current: {
46 oldPath: string | null;
47 newPath: string | null;
48 lines: ParsedDiffLine[];
49 } | null = null;
50 let oldLine: number | null = null;
51 let newLine: number | null = null;
52
53 const pushCurrent = () => {
54 if (!current) {
55 return;
56 }
57 files.push({
58 id: `${current.oldPath ?? "null"}:${current.newPath ?? "null"}:${files.length}`,
59 oldPath: current.oldPath,
60 newPath: current.newPath,
61 lines: current.lines,
62 });
63 };
64
65 for (const rawLine of diff.replace(/\r\n/g, "\n").split("\n")) {
66 if (rawLine.startsWith("diff --git ")) {
67 pushCurrent();
68 const match = rawLine.match(/^diff --git a\/(.+) b\/(.+)$/);
69 current = {
70 oldPath: match?.[1] ?? null,
71 newPath: match?.[2] ?? null,
72 lines: [],
73 };
74 oldLine = null;
75 newLine = null;
76 continue;
77 }
78
79 if (!current) {
80 if (rawLine.trim().length === 0) {
81 continue;
82 }
83 current = { oldPath: null, newPath: null, lines: [] };
84 }
85
86 const oldPath = parseDiffPath(rawLine, "--- ");
87 if (oldPath !== null || rawLine === "--- /dev/null") {
88 current.oldPath = oldPath;
89 continue;
90 }
91
92 const newPath = parseDiffPath(rawLine, "+++ ");
93 if (newPath !== null || rawLine === "+++ /dev/null") {
94 current.newPath = newPath;
95 continue;
96 }
97
98 const hunk = parseHunkStart(rawLine);
99 if (hunk) {
100 oldLine = hunk.oldLine;
101 newLine = hunk.newLine;
102 current.lines.push({
103 id: `${current.lines.length}:hunk`,
104 type: "hunk",
105 oldLine: null,
106 newLine: null,
107 content: rawLine,
108 });
109 continue;
110 }
111
112 if (oldLine === null || newLine === null) {
113 current.lines.push({
114 id: `${current.lines.length}:meta`,
115 type: "meta",
116 oldLine: null,
117 newLine: null,
118 content: rawLine,
119 });
120 continue;
121 }
122
123 const marker = rawLine[0];
124 const content = rawLine.length > 0 ? rawLine.slice(1) : "";
125 if (marker === "+") {
126 current.lines.push({
127 id: `${current.lines.length}:add:${newLine}`,
128 type: "add",
129 oldLine: null,
130 newLine,
131 content,
132 });
133 newLine += 1;
134 } else if (marker === "-") {
135 current.lines.push({
136 id: `${current.lines.length}:delete:${oldLine}`,
137 type: "delete",
138 oldLine,
139 newLine: null,
140 content,
141 });
142 oldLine += 1;
143 } else {
144 current.lines.push({
145 id: `${current.lines.length}:context:${oldLine}:${newLine}`,
146 type: "context",
147 oldLine,
148 newLine,
149 content: marker === " " ? content : rawLine,
150 });
151 oldLine += 1;
152 newLine += 1;
153 }
154 }
155
156 pushCurrent();
157 return files.filter((file) => file.lines.length > 0 || file.oldPath || file.newPath);
158}
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts created+431
...@@ -0,0 +1,431 @@
1import type { NativeReviewDiffRow, NativeReviewDiffTheme } from "../diffs/nativeReviewDiffSurface";
2import type {
3 NativeReviewDiffFile,
4 NativeReviewDiffLanguage,
5} from "../diffs/nativeReviewDiffTypes";
6import * as Arr from "effect/Array";
7import { pipe } from "effect/Function";
8import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
9import { computeWordAltDiffRanges } from "./reviewWordDiffs";
10import {
11 getReviewFilePreviewState,
12 type ReviewParsedDiff,
13 type ReviewRenderableFile,
14 type ReviewRenderableLineRow,
15} from "./reviewModel";
16import type { ReviewInlineComment } from "./reviewCommentSelection";
17
18const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
19const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
20
21export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = 20;
22export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
23
24export const NATIVE_REVIEW_DIFF_STYLE = {
25 rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT,
26 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
27 changeBarWidth: 4,
28 gutterWidth: 46,
29 codePadding: 7,
30 textVerticalInset: 2,
31 fileHeaderHeight: 56,
32 fileHeaderHorizontalMargin: 8,
33 fileHeaderVerticalMargin: 6,
34 fileHeaderCornerRadius: 10,
35 fileHeaderHorizontalPadding: 10,
36 fileHeaderPathRightPadding: 118,
37 fileHeaderCountColumnWidth: 38,
38 fileHeaderCountGap: 5,
39 codeFontSize: 11,
40 codeFontWeight: "regular",
41 lineNumberFontSize: 10,
42 lineNumberFontWeight: "regular",
43 hunkFontSize: 11,
44 hunkFontWeight: "medium",
45 fileHeaderFontSize: 11,
46 fileHeaderFontWeight: "semibold",
47 fileHeaderMetaFontSize: 10,
48 fileHeaderMetaFontWeight: "semibold",
49 fileHeaderSubtextFontSize: 11,
50 fileHeaderSubtextFontWeight: "medium",
51 fileHeaderStatusFontSize: 9,
52 fileHeaderStatusFontWeight: "bold",
53 emptyStateFontSize: 12,
54 emptyStateFontWeight: "medium",
55} as const;
56
57export interface NativeReviewDiffData {
58 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
59 readonly files: ReadonlyArray<NativeReviewDiffFile>;
60 readonly commentTargetsByRowId: ReadonlyMap<string, NativeReviewDiffCommentTarget>;
61 readonly rowIdByCommentLineId: ReadonlyMap<string, string>;
62 readonly additions: number;
63 readonly deletions: number;
64}
65
66export interface NativeReviewDiffCommentTarget {
67 readonly filePath: string;
68 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
69 readonly lineIndex: number;
70}
71
72export interface BuildNativeReviewDiffDataInput {
73 readonly parsedDiff: ReviewParsedDiff;
74 readonly comments?: ReadonlyArray<ReviewInlineComment>;
75}
76
77export function createNativeReviewDiffTheme(
78 scheme: TerminalAppearanceScheme,
79): NativeReviewDiffTheme {
80 const terminalTheme = getPierreTerminalTheme(scheme);
81 const [, terminalRed, , , terminalBlue] = terminalTheme.palette;
82
83 if (scheme === "dark") {
84 return {
85 background: terminalTheme.background,
86 text: terminalTheme.foreground,
87 mutedText: terminalTheme.mutedForeground,
88 headerBackground: terminalTheme.background,
89 border: terminalTheme.border,
90 hunkBackground: "#071f28",
91 hunkText: terminalBlue ?? "#009fff",
92 addBackground: "#0d2f28",
93 deleteBackground: "#391415",
94 addBar: "#00cab1",
95 deleteBar: terminalRed ?? "#ff2e3f",
96 addText: "#5ECC71",
97 deleteText: "#FF6762",
98 };
99 }
100
101 return {
102 background: "#ffffff",
103 text: "#070707",
104 mutedText: terminalTheme.mutedForeground,
105 headerBackground: "#ffffff",
106 border: terminalTheme.border,
107 hunkBackground: "#e0f2ff",
108 hunkText: terminalBlue ?? "#009fff",
109 addBackground: "#e5f8f5",
110 deleteBackground: "#ffe6e7",
111 addBar: "#00cab1",
112 deleteBar: terminalRed ?? "#ff2e3f",
113 addText: "#199F43",
114 deleteText: "#D52C36",
115 };
116}
117
118function mapChangeType(file: ReviewRenderableFile): NativeReviewDiffRow["changeType"] {
119 switch (file.changeType) {
120 case "change":
121 return "modified";
122 case "new":
123 case "deleted":
124 case "rename-pure":
125 case "rename-changed":
126 return file.changeType;
127 default:
128 return "modified";
129 }
130}
131
132function getLanguageForPath(
133 filePath: string,
134 languageHint: string | null,
135): NativeReviewDiffLanguage {
136 const hinted = languageHint?.toLowerCase();
137 if (hinted === "typescript" || hinted === "tsx" || hinted === "javascript" || hinted === "jsx") {
138 return hinted;
139 }
140 if (hinted === "json" || hinted === "yaml" || hinted === "bash" || hinted === "diff") {
141 return hinted;
142 }
143
144 const normalizedPath = filePath.toLowerCase();
145 if (normalizedPath.endsWith(".tsx")) return "tsx";
146 if (normalizedPath.endsWith(".ts")) return "typescript";
147 if (normalizedPath.endsWith(".jsx")) return "jsx";
148 if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".cjs")) return "javascript";
149 if (normalizedPath.endsWith(".json") || normalizedPath.endsWith(".jsonc")) return "json";
150 if (normalizedPath.endsWith(".yml") || normalizedPath.endsWith(".yaml")) return "yaml";
151 if (
152 normalizedPath.endsWith(".sh") ||
153 normalizedPath.includes("/bin/") ||
154 normalizedPath.includes("shell")
155 ) {
156 return "bash";
157 }
158 return "diff";
159}
160
161function createNoticeRow(fileId: string, suffix: string, text: string): NativeReviewDiffRow {
162 return {
163 kind: "notice",
164 id: `${fileId}:notice:${suffix}`,
165 fileId,
166 text,
167 };
168}
169
170function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray<NativeReviewDiffRow> {
171 if (file.rows.length > 0) {
172 return [];
173 }
174
175 const previewState = getReviewFilePreviewState(file);
176 if (previewState.kind === "suppressed" && previewState.reason === "non-text") {
177 return [
178 createNoticeRow(file.id, "non-text", "Unsupported format. Diff contents are not available."),
179 ];
180 }
181
182 if (file.changeType === "rename-pure") {
183 return [createNoticeRow(file.id, "rename", "This file was renamed without modifications.")];
184 }
185
186 return [];
187}
188
189function trimWordDiffRanges(
190 content: string,
191 ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>,
192): NonNullable<NativeReviewDiffRow["wordDiffRanges"]> {
193 return pipe(
194 ranges,
195 Arr.flatMap((range) => {
196 let start = Math.max(0, range.start);
197 let end = Math.min(content.length, range.end);
198
199 while (start < end && /\s/.test(content[start] ?? "")) {
200 start += 1;
201 }
202 while (end > start && /\s/.test(content[end - 1] ?? "")) {
203 end -= 1;
204 }
205
206 return end > start ? [{ start, end }] : [];
207 }),
208 );
209}
210
211function nonWhitespaceLength(value: string) {
212 return value.replace(/\s/g, "").length;
213}
214
215function shouldUseWordDiffRanges(
216 content: string,
217 ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>,
218) {
219 if (ranges.length === 0 || ranges.length > NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT) {
220 return false;
221 }
222
223 const meaningfulLength = nonWhitespaceLength(content);
224 if (meaningfulLength === 0) {
225 return false;
226 }
227
228 const highlightedLength = ranges.reduce(
229 (total, range) => total + nonWhitespaceLength(content.slice(range.start, range.end)),
230 0,
231 );
232 return highlightedLength / meaningfulLength <= NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE;
233}
234
235function addNativeWordDiffRanges(
236 rows: ReadonlyArray<NativeReviewDiffRow>,
237): ReadonlyArray<NativeReviewDiffRow> {
238 const nextRows = [...rows];
239 let index = 0;
240
241 while (index < nextRows.length) {
242 const deletedRowIndexes: number[] = [];
243 const addedRowIndexes: number[] = [];
244 const fileId = nextRows[index]?.fileId;
245
246 while (
247 nextRows[index]?.kind === "line" &&
248 nextRows[index]?.change === "delete" &&
249 nextRows[index]?.fileId === fileId
250 ) {
251 deletedRowIndexes.push(index);
252 index += 1;
253 }
254
255 while (
256 nextRows[index]?.kind === "line" &&
257 nextRows[index]?.change === "add" &&
258 nextRows[index]?.fileId === fileId
259 ) {
260 addedRowIndexes.push(index);
261 index += 1;
262 }
263
264 const pairedCount = Math.min(deletedRowIndexes.length, addedRowIndexes.length);
265 for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) {
266 const deletedRowIndex = deletedRowIndexes[pairIndex];
267 const addedRowIndex = addedRowIndexes[pairIndex];
268 const deletedRow = nextRows[deletedRowIndex];
269 const addedRow = nextRows[addedRowIndex];
270 if (!deletedRow?.content || !addedRow?.content) {
271 continue;
272 }
273
274 const ranges = computeWordAltDiffRanges({
275 deletionLine: deletedRow.content,
276 additionLine: addedRow.content,
277 });
278 const deletionRanges = trimWordDiffRanges(deletedRow.content, ranges.deletion);
279 const additionRanges = trimWordDiffRanges(addedRow.content, ranges.addition);
280
281 if (shouldUseWordDiffRanges(deletedRow.content, deletionRanges)) {
282 nextRows[deletedRowIndex] = { ...deletedRow, wordDiffRanges: deletionRanges };
283 }
284 if (shouldUseWordDiffRanges(addedRow.content, additionRanges)) {
285 nextRows[addedRowIndex] = { ...addedRow, wordDiffRanges: additionRanges };
286 }
287 }
288
289 if (deletedRowIndexes.length === 0 && addedRowIndexes.length === 0) {
290 index += 1;
291 }
292 }
293
294 return nextRows;
295}
296
297function mapLineRow(
298 file: ReviewRenderableFile,
299 row: ReviewRenderableLineRow,
300 rowIndex: number,
301): NativeReviewDiffRow {
302 return {
303 kind: "line",
304 id: `${file.id}:line:${rowIndex}:${row.id}`,
305 fileId: file.id,
306 content: row.content,
307 change: row.change,
308 oldLineNumber: row.oldLineNumber,
309 newLineNumber: row.newLineNumber,
310 };
311}
312
313function mapFileRows(
314 file: ReviewRenderableFile,
315 comments: ReadonlyArray<ReviewInlineComment>,
316 commentTargetsByRowId: Map<string, NativeReviewDiffCommentTarget>,
317 rowIdByCommentLineId: Map<string, string>,
318): ReadonlyArray<NativeReviewDiffRow> {
319 const rows: NativeReviewDiffRow[] = [
320 {
321 kind: "file",
322 id: `${file.id}:header`,
323 fileId: file.id,
324 filePath: file.path,
325 previousPath: file.previousPath,
326 changeType: mapChangeType(file),
327 additions: file.additions,
328 deletions: file.deletions,
329 },
330 ];
331
332 const lineRows = file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line");
333 const commentsByEndIndex = new Map<number, ReviewInlineComment[]>();
334 comments.forEach((comment) => {
335 if (comment.filePath !== file.path) {
336 return;
337 }
338 const endIndex = Math.min(comment.endIndex, lineRows.length - 1);
339 if (endIndex < 0) {
340 return;
341 }
342 const existing = commentsByEndIndex.get(endIndex);
343 if (existing) {
344 existing.push(comment);
345 return;
346 }
347 commentsByEndIndex.set(endIndex, [comment]);
348 });
349 let lineIndex = 0;
350 file.rows.forEach((row, rowIndex) => {
351 if (row.kind === "hunk") {
352 rows.push({
353 kind: "hunk",
354 id: `${file.id}:hunk:${rowIndex}:${row.id}`,
355 fileId: file.id,
356 text: row.context ? `${row.header} ${row.context}` : row.header,
357 });
358 return;
359 }
360
361 const nativeRow = mapLineRow(file, row, rowIndex);
362 rows.push(nativeRow);
363 rowIdByCommentLineId.set(row.id, nativeRow.id);
364 commentTargetsByRowId.set(nativeRow.id, {
365 filePath: file.path,
366 lines: lineRows,
367 lineIndex,
368 });
369 const commentsForLine = commentsByEndIndex.get(lineIndex) ?? [];
370 for (const comment of commentsForLine) {
371 rows.push({
372 kind: "comment",
373 id: comment.id,
374 fileId: file.id,
375 filePath: file.path,
376 commentText: comment.text,
377 commentRangeLabel: comment.rangeLabel,
378 commentSectionTitle: comment.sectionTitle,
379 });
380 }
381 lineIndex += 1;
382 });
383
384 rows.push(...noticeRowsForFile(file));
385 return rows;
386}
387
388export function buildNativeReviewDiffData(
389 input: BuildNativeReviewDiffDataInput,
390): NativeReviewDiffData;
391export function buildNativeReviewDiffData(parsedDiff: ReviewParsedDiff): NativeReviewDiffData;
392export function buildNativeReviewDiffData(
393 input: ReviewParsedDiff | BuildNativeReviewDiffDataInput,
394): NativeReviewDiffData {
395 const parsedDiff = "parsedDiff" in input ? input.parsedDiff : input;
396 const comments = "parsedDiff" in input ? (input.comments ?? []) : [];
397 if (parsedDiff.kind !== "files") {
398 return {
399 rows: [],
400 files: [],
401 commentTargetsByRowId: new Map(),
402 rowIdByCommentLineId: new Map(),
403 additions: 0,
404 deletions: 0,
405 };
406 }
407
408 const files = parsedDiff.files.map<NativeReviewDiffFile>((file) => ({
409 id: file.id,
410 path: file.path,
411 language: getLanguageForPath(file.path, file.languageHint),
412 additions: file.additions,
413 deletions: file.deletions,
414 }));
415 const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>();
416 const rowIdByCommentLineId = new Map<string, string>();
417 const rows = addNativeWordDiffRanges(
418 Arr.flatMap(parsedDiff.files, (file) =>
419 mapFileRows(file, comments, commentTargetsByRowId, rowIdByCommentLineId),
420 ),
421 );
422
423 return {
424 rows,
425 files,
426 commentTargetsByRowId,
427 rowIdByCommentLineId,
428 additions: parsedDiff.additions,
429 deletions: parsedDiff.deletions,
430 };
431}
apps/mobile/src/features/review/reviewCommentSelection.test.ts created+81
...@@ -0,0 +1,81 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 countReviewCommentContexts,
5 formatReviewCommentContext,
6 parseReviewCommentMessageSegments,
7 parseReviewInlineComments,
8 type ReviewCommentTarget,
9} from "./reviewCommentSelection";
10
11function makeTarget(): ReviewCommentTarget {
12 return {
13 sectionId: "section-1",
14 sectionTitle: "Working tree",
15 filePath: "apps/demo/src/main.ts",
16 startIndex: 0,
17 endIndex: 1,
18 lines: [
19 {
20 kind: "line",
21 id: "line-1",
22 change: "delete",
23 oldLineNumber: 7,
24 newLineNumber: null,
25 content: "const retryLimit = 2;",
26 additionTokenIndex: null,
27 deletionTokenIndex: 0,
28 comparison: null,
29 },
30 {
31 kind: "line",
32 id: "line-2",
33 change: "add",
34 oldLineNumber: null,
35 newLineNumber: 7,
36 content: "const retryLimit = 4;",
37 additionTokenIndex: 0,
38 deletionTokenIndex: null,
39 comparison: null,
40 },
41 ],
42 };
43}
44
45describe("review comment serialization", () => {
46 it("preserves enough metadata for inline diff rendering", () => {
47 const serialized = formatReviewCommentContext(makeTarget(), "Please keep this configurable.");
48
49 expect(countReviewCommentContexts(serialized)).toBe(1);
50 expect(parseReviewInlineComments(serialized)).toEqual([
51 expect.objectContaining({
52 sectionId: "section-1",
53 sectionTitle: "Working tree",
54 filePath: "apps/demo/src/main.ts",
55 startIndex: 0,
56 endIndex: 1,
57 text: "Please keep this configurable.",
58 diff: expect.stringContaining("-const retryLimit = 2;"),
59 }),
60 ]);
61 });
62
63 it("splits chat text into review comment segments", () => {
64 const serialized = `Before\n${formatReviewCommentContext(makeTarget(), "Please keep this configurable.")}\nAfter`;
65 const segments = parseReviewCommentMessageSegments(serialized);
66
67 expect(segments).toHaveLength(3);
68 expect(segments[0]).toEqual(expect.objectContaining({ kind: "text", text: "Before\n" }));
69 expect(segments[1]).toEqual(
70 expect.objectContaining({
71 kind: "review-comment",
72 comment: expect.objectContaining({
73 filePath: "apps/demo/src/main.ts",
74 text: "Please keep this configurable.",
75 diff: expect.stringContaining("+const retryLimit = 4;"),
76 }),
77 }),
78 );
79 expect(segments[2]).toEqual(expect.objectContaining({ kind: "text", text: "\nAfter" }));
80 });
81});
apps/mobile/src/features/review/reviewCommentSelection.ts created+317
...@@ -0,0 +1,317 @@
1import { useSyncExternalStore } from "react";
2
3import type { ReviewRenderableLineRow } from "./reviewModel";
4
5export interface ReviewCommentTarget {
6 readonly sectionId: string;
7 readonly sectionTitle: string;
8 readonly filePath: string;
9 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
10 readonly startIndex: number;
11 readonly endIndex: number;
12}
13
14export interface ReviewInlineComment {
15 readonly id: string;
16 readonly sectionId: string;
17 readonly sectionTitle: string;
18 readonly filePath: string;
19 readonly startIndex: number;
20 readonly endIndex: number;
21 readonly rangeLabel: string;
22 readonly text: string;
23 readonly diff: string;
24}
25
26export type ReviewCommentMessageSegment =
27 | {
28 readonly kind: "text";
29 readonly id: string;
30 readonly text: string;
31 }
32 | {
33 readonly kind: "review-comment";
34 readonly comment: ReviewInlineComment;
35 };
36
37let currentTarget: ReviewCommentTarget | null = null;
38const listeners = new Set<() => void>();
39const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;
40const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
41
42function emitChange() {
43 listeners.forEach((listener) => listener());
44}
45
46export function subscribeReviewCommentTarget(listener: () => void): () => void {
47 listeners.add(listener);
48 return () => {
49 listeners.delete(listener);
50 };
51}
52
53export function getReviewCommentTarget(): ReviewCommentTarget | null {
54 return currentTarget;
55}
56
57export function setReviewCommentTarget(target: ReviewCommentTarget | null) {
58 currentTarget = target;
59 emitChange();
60}
61
62export function clearReviewCommentTarget() {
63 currentTarget = null;
64 emitChange();
65}
66
67export function useReviewCommentTarget(): ReviewCommentTarget | null {
68 return useSyncExternalStore(
69 subscribeReviewCommentTarget,
70 getReviewCommentTarget,
71 getReviewCommentTarget,
72 );
73}
74
75export function getSelectedReviewCommentLines(
76 target: ReviewCommentTarget,
77): ReadonlyArray<ReviewRenderableLineRow> {
78 return target.lines.slice(target.startIndex, target.endIndex + 1);
79}
80
81export function getReviewUnifiedLineNumber(line: ReviewRenderableLineRow): number | null {
82 return line.newLineNumber ?? line.oldLineNumber;
83}
84
85export function formatReviewLineLabel(line: ReviewRenderableLineRow): string {
86 if (line.newLineNumber !== null) {
87 return `new line ${line.newLineNumber}`;
88 }
89 if (line.oldLineNumber !== null) {
90 return `old line ${line.oldLineNumber}`;
91 }
92 return "file";
93}
94
95export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string {
96 if (change === "add") return "+";
97 if (change === "delete") return "-";
98 return " ";
99}
100
101export function buildReviewCommentTarget(
102 target: Pick<ReviewCommentTarget, "sectionId" | "sectionTitle" | "filePath" | "lines">,
103 anchorIndex: number,
104 lineIndex: number,
105): ReviewCommentTarget {
106 return {
107 sectionId: target.sectionId,
108 sectionTitle: target.sectionTitle,
109 filePath: target.filePath,
110 lines: target.lines,
111 startIndex: Math.min(anchorIndex, lineIndex),
112 endIndex: Math.max(anchorIndex, lineIndex),
113 };
114}
115
116export function formatReviewSelectedRangeLabel(target: ReviewCommentTarget): string {
117 const lines = getSelectedReviewCommentLines(target);
118 const firstLine = lines[0]!;
119 const lastLine = lines[lines.length - 1]!;
120 const firstNumber = getReviewUnifiedLineNumber(firstLine);
121 const lastNumber = getReviewUnifiedLineNumber(lastLine);
122
123 if (firstNumber === null || lastNumber === null) {
124 return lines.length === 1 ? "line" : `${lines.length} lines`;
125 }
126
127 const firstMarker = getReviewChangeMarker(firstLine.change).trim();
128 const consistentMarker =
129 lines.every((line) => line.change === firstLine.change) && firstMarker.length > 0
130 ? getReviewChangeMarker(firstLine.change)
131 : "";
132
133 if (firstNumber === lastNumber) {
134 return `${consistentMarker}${firstNumber}`;
135 }
136
137 return `${consistentMarker}${firstNumber} to ${consistentMarker}${lastNumber}`;
138}
139
140function getDiffHunkRange(
141 selectedLines: ReadonlyArray<ReviewRenderableLineRow>,
142 key: "oldLineNumber" | "newLineNumber",
143): {
144 readonly start: number;
145 readonly count: number;
146} {
147 const numberedLines = selectedLines.filter((line) => line[key] !== null);
148 if (numberedLines.length === 0) {
149 return { start: 0, count: 0 };
150 }
151
152 return {
153 start: numberedLines[0]![key] ?? 0,
154 count: numberedLines.length,
155 };
156}
157
158function formatReviewSelectedDiff(target: ReviewCommentTarget): string {
159 const selectedLines = getSelectedReviewCommentLines(target);
160 const oldRange = getDiffHunkRange(selectedLines, "oldLineNumber");
161 const newRange = getDiffHunkRange(selectedLines, "newLineNumber");
162 const diffBody = selectedLines
163 .map((line) => `${getReviewChangeMarker(line.change)}${line.content}`)
164 .join("\n");
165
166 return [
167 `@@ -${oldRange.start},${oldRange.count} +${newRange.start},${newRange.count} @@`,
168 diffBody.length > 0 ? diffBody : " ",
169 ].join("\n");
170}
171
172function escapeReviewCommentAttribute(value: string): string {
173 return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
174}
175
176function unescapeReviewCommentAttribute(value: string): string {
177 return value
178 .replace(/&lt;/g, "<")
179 .replace(/&quot;/g, '"')
180 .replace(/&amp;/g, "&");
181}
182
183function readReviewCommentAttributes(rawAttributes: string): Record<string, string> {
184 const attributes: Record<string, string> = {};
185 for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) {
186 attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? "");
187 }
188 return attributes;
189}
190
191function readNonNegativeInteger(value: string | undefined): number | null {
192 if (value === undefined || !/^\d+$/.test(value)) {
193 return null;
194 }
195 return Number(value);
196}
197
198function extractReviewCommentText(rawBody: string): string {
199 const fenceIndex = rawBody.indexOf("```diff");
200 const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody;
201 return commentBody.trim();
202}
203
204function extractReviewCommentDiff(rawBody: string): string {
205 const match = rawBody.match(/```diff\s*\n([\s\S]*?)\n```/);
206 return match?.[1]?.trim() ?? "";
207}
208
209function parseReviewInlineComment(
210 rawAttributes: string,
211 rawBody: string,
212 index: number,
213): ReviewInlineComment | null {
214 const attributes = readReviewCommentAttributes(rawAttributes);
215 const startIndex = readNonNegativeInteger(attributes.startIndex);
216 const endIndex = readNonNegativeInteger(attributes.endIndex);
217 const filePath = attributes.filePath?.trim();
218 const sectionId = attributes.sectionId?.trim();
219 if (!filePath || !sectionId || startIndex === null || endIndex === null) {
220 return null;
221 }
222
223 return {
224 id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,
225 sectionId,
226 sectionTitle: attributes.sectionTitle?.trim() || "Review",
227 filePath,
228 startIndex: Math.min(startIndex, endIndex),
229 endIndex: Math.max(startIndex, endIndex),
230 rangeLabel: attributes.rangeLabel?.trim() || "line",
231 text: extractReviewCommentText(rawBody),
232 diff: extractReviewCommentDiff(rawBody),
233 };
234}
235
236export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string {
237 const rangeLabel = formatReviewSelectedRangeLabel(target);
238 return [
239 [
240 "<review_comment",
241 ` sectionId="${escapeReviewCommentAttribute(target.sectionId)}"`,
242 ` sectionTitle="${escapeReviewCommentAttribute(target.sectionTitle)}"`,
243 ` filePath="${escapeReviewCommentAttribute(target.filePath)}"`,
244 ` startIndex="${target.startIndex}"`,
245 ` endIndex="${target.endIndex}"`,
246 ` rangeLabel="${escapeReviewCommentAttribute(rangeLabel)}"`,
247 ">",
248 ].join(""),
249 comment.trim(),
250 "```diff",
251 formatReviewSelectedDiff(target),
252 "```",
253 "</review_comment>",
254 ].join("\n");
255}
256
257export function countReviewCommentContexts(value: string): number {
258 return Array.from(value.matchAll(/<review_comment\b/g)).length;
259}
260
261export function parseReviewInlineComments(value: string): ReadonlyArray<ReviewInlineComment> {
262 const comments: ReviewInlineComment[] = [];
263 for (const [index, match] of Array.from(value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)).entries()) {
264 const comment = parseReviewInlineComment(match[1] ?? "", match[2] ?? "", index);
265 if (!comment) {
266 continue;
267 }
268
269 comments.push(comment);
270 }
271 return comments;
272}
273
274export function parseReviewCommentMessageSegments(
275 value: string,
276): ReadonlyArray<ReviewCommentMessageSegment> {
277 const segments: ReviewCommentMessageSegment[] = [];
278 let cursor = 0;
279 let parsedCommentIndex = 0;
280
281 for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) {
282 const matchIndex = match.index ?? 0;
283 const beforeText = value.slice(cursor, matchIndex);
284 if (beforeText.length > 0) {
285 segments.push({
286 kind: "text",
287 id: `review-comment-text:${cursor}`,
288 text: beforeText,
289 });
290 }
291
292 const comment = parseReviewInlineComment(match[1] ?? "", match[2] ?? "", parsedCommentIndex);
293 if (comment) {
294 segments.push({ kind: "review-comment", comment });
295 parsedCommentIndex += 1;
296 } else {
297 segments.push({
298 kind: "text",
299 id: `review-comment-invalid:${matchIndex}`,
300 text: match[0],
301 });
302 }
303
304 cursor = matchIndex + match[0].length;
305 }
306
307 const rest = value.slice(cursor);
308 if (rest.length > 0) {
309 segments.push({
310 kind: "text",
311 id: `review-comment-text:${cursor}`,
312 text: rest,
313 });
314 }
315
316 return segments;
317}
apps/mobile/src/features/review/reviewDiffPreviewState.ts created+110
...@@ -0,0 +1,110 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import { AsyncResult, Atom } from "effect/unstable/reactivity";
7import { useCallback, useMemo } from "react";
8
9import { appAtomRegistry } from "../../state/atom-registry";
10import { getEnvironmentClient } from "../../state/environment-session-registry";
11
12const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000;
13const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000;
14const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f";
15
16export interface ReviewDiffPreviewState {
17 readonly data: ReviewDiffPreviewResult | null;
18 readonly error: string | null;
19 readonly isPending: boolean;
20 readonly refresh: () => void;
21}
22
23function makeReviewDiffPreviewKey(input: {
24 readonly environmentId: EnvironmentId;
25 readonly cwd: string;
26}): string {
27 return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`;
28}
29
30function parseReviewDiffPreviewKey(key: string): {
31 readonly environmentId: EnvironmentId;
32 readonly cwd: string;
33} {
34 const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR);
35 return {
36 environmentId: environmentId as EnvironmentId,
37 cwd,
38 };
39}
40
41const reviewDiffPreviewAtom = Atom.family((key: string) =>
42 Atom.make(
43 Effect.promise(async (): Promise<ReviewDiffPreviewResult> => {
44 const target = parseReviewDiffPreviewKey(key);
45 const client = getEnvironmentClient(target.environmentId);
46 if (!client) {
47 throw new Error("Remote connection is not ready.");
48 }
49 return client.review.getDiffPreview({ cwd: target.cwd });
50 }),
51 ).pipe(
52 Atom.swr({
53 staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS,
54 revalidateOnMount: true,
55 }),
56 Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS),
57 Atom.withLabel(`mobile:review:diff-preview:${key}`),
58 ),
59);
60
61const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make(
62 AsyncResult.initial<ReviewDiffPreviewResult, never>(false),
63).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null"));
64
65function readReviewDiffPreviewError(
66 result: AsyncResult.AsyncResult<ReviewDiffPreviewResult, unknown>,
67): string | null {
68 if (result._tag !== "Failure") {
69 return null;
70 }
71
72 const error = Cause.squash(result.cause);
73 return error instanceof Error ? error.message : "Failed to load review diffs.";
74}
75
76export function useReviewDiffPreview(input: {
77 readonly environmentId?: EnvironmentId;
78 readonly cwd: string | null;
79}): ReviewDiffPreviewState {
80 const key = useMemo(() => {
81 if (!input.environmentId || !input.cwd) {
82 return null;
83 }
84 return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd });
85 }, [input.cwd, input.environmentId]);
86
87 const atom = key ? reviewDiffPreviewAtom(key) : null;
88 const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM);
89 const refresh = useCallback(() => {
90 if (atom) {
91 appAtomRegistry.refresh(atom);
92 }
93 }, [atom]);
94
95 if (!atom) {
96 return {
97 data: null,
98 error: null,
99 isPending: false,
100 refresh,
101 };
102 }
103
104 return {
105 data: Option.getOrNull(AsyncResult.value(result)),
106 error: readReviewDiffPreviewError(result),
107 isPending: result.waiting,
108 refresh,
109 };
110}
apps/mobile/src/features/review/reviewDiffRendering.tsx created+128
...@@ -0,0 +1,128 @@
1import { Platform, Text as NativeText, View } from "react-native";
2
3import { cn } from "../../lib/cn";
4
5import type { ReviewRenderableLineRow } from "./reviewModel";
6import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
7
8export const REVIEW_MONO_FONT_FAMILY = Platform.select({
9 ios: "ui-monospace",
10 android: "monospace",
11 default: "monospace",
12});
13
14export const REVIEW_DIFF_LINE_HEIGHT = 26;
15const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2;
16
17export function renderVisibleWhitespace(value: string): string {
18 const expandedTabs = value.replace(/\t/g, " ");
19 return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0"));
20}
21
22export function changeTone(change: ReviewRenderableLineRow["change"]): string {
23 if (change === "add") return "bg-emerald-500/10";
24 if (change === "delete") return "bg-rose-500/10";
25 return "bg-card";
26}
27
28export function changeBarTone(change: ReviewRenderableLineRow["change"]): string {
29 if (change === "add") return "bg-emerald-400";
30 if (change === "delete") return "bg-rose-400";
31 return "bg-border/50";
32}
33
34function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | undefined {
35 if (change === "add") return "rgba(16, 185, 129, 0.24)";
36 if (change === "delete") return "rgba(244, 63, 94, 0.24)";
37 return undefined;
38}
39
40export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) {
41 if (props.change === "delete") {
42 return (
43 <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>
44 <View>
45 {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => (
46 <View key={index}>
47 <View className="w-[5px] bg-rose-400" style={{ height: 1 }} />
48 <View style={{ height: 1 }} />
49 </View>
50 ))}
51 </View>
52 </View>
53 );
54 }
55
56 return (
57 <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>
58 <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} />
59 </View>
60 );
61}
62
63export function DiffTokenText(props: {
64 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
65 readonly fallback: string;
66 readonly change?: ReviewRenderableLineRow["change"];
67 readonly className?: string;
68}) {
69 if (!props.tokens || props.tokens.length === 0) {
70 return (
71 <NativeText
72 numberOfLines={1}
73 selectable
74 className={cn("text-[13px] leading-[17px] font-medium text-foreground", props.className)}
75 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
76 >
77 {renderVisibleWhitespace(props.fallback || " ")}
78 </NativeText>
79 );
80 }
81
82 return (
83 <NativeText
84 numberOfLines={1}
85 selectable
86 className={cn("text-[13px] leading-[17px] font-medium text-foreground", props.className)}
87 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
88 >
89 {(() => {
90 let offset = 0;
91
92 return props.tokens.map((token) => {
93 const start = offset;
94 offset += token.content.length;
95
96 const fontWeight =
97 token.fontStyle !== null && (token.fontStyle & 2) === 2
98 ? ("700" as const)
99 : ("500" as const);
100 const fontStyle =
101 token.fontStyle !== null && (token.fontStyle & 1) === 1
102 ? ("italic" as const)
103 : ("normal" as const);
104
105 return (
106 <NativeText
107 key={`${start}:${token.content.length}:${token.color ?? ""}:${token.fontStyle ?? ""}`}
108 selectable
109 style={{
110 color: token.color ?? undefined,
111 fontFamily: REVIEW_MONO_FONT_FAMILY,
112 fontWeight,
113 fontStyle,
114 backgroundColor:
115 token.diffHighlight && props.change
116 ? diffHighlightColor(props.change)
117 : undefined,
118 borderRadius: token.diffHighlight ? 4 : undefined,
119 }}
120 >
121 {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
122 </NativeText>
123 );
124 });
125 })()}
126 </NativeText>
127 );
128}
apps/mobile/src/features/review/reviewFileVisibility.test.ts created+50
...@@ -0,0 +1,50 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 getDefaultReviewExpandedFileIds,
5 getValidExplicitReviewFileIds,
6 getValidReviewFileIds,
7 removeReviewFileId,
8 toggleReviewFileId,
9} from "./reviewFileVisibility";
10import type { ReviewRenderableFile } from "./reviewModel";
11
12function makeFile(id: string): ReviewRenderableFile {
13 return {
14 id,
15 cacheKey: id,
16 path: id,
17 previousPath: null,
18 changeType: "change",
19 additions: 0,
20 deletions: 0,
21 languageHint: null,
22 additionLines: [],
23 deletionLines: [],
24 rows: [],
25 };
26}
27
28describe("review file visibility", () => {
29 const files = [makeFile("a.ts"), makeFile("b.ts")];
30
31 it("defaults expanded files to every renderable file", () => {
32 expect(getDefaultReviewExpandedFileIds(files)).toEqual(["a.ts", "b.ts"]);
33 expect(getValidReviewFileIds(files, undefined)).toEqual(["a.ts", "b.ts"]);
34 });
35
36 it("filters stale cached file ids", () => {
37 expect(getValidReviewFileIds(files, ["missing.ts", "b.ts"])).toEqual(["b.ts"]);
38 expect(getValidExplicitReviewFileIds(files, undefined)).toEqual([]);
39 expect(getValidExplicitReviewFileIds(files, ["a.ts", "missing.ts"])).toEqual(["a.ts"]);
40 });
41
42 it("toggles and removes ids without mutating the original array", () => {
43 const original = ["a.ts"];
44
45 expect(toggleReviewFileId(original, "b.ts")).toEqual(["a.ts", "b.ts"]);
46 expect(toggleReviewFileId(original, "a.ts")).toEqual([]);
47 expect(removeReviewFileId(original, "a.ts")).toEqual([]);
48 expect(original).toEqual(["a.ts"]);
49 });
50});
apps/mobile/src/features/review/reviewFileVisibility.ts created+116
...@@ -0,0 +1,116 @@
1import { useCallback, useMemo } from "react";
2
3import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState";
4import type { ReviewRenderableFile } from "./reviewModel";
5
6export function getDefaultReviewExpandedFileIds(
7 files: ReadonlyArray<ReviewRenderableFile>,
8): ReadonlyArray<string> {
9 return files.map((file) => file.id);
10}
11
12export function getValidReviewFileIds(
13 files: ReadonlyArray<ReviewRenderableFile>,
14 fileIds: ReadonlyArray<string> | undefined,
15): ReadonlyArray<string> {
16 if (fileIds === undefined) {
17 return getDefaultReviewExpandedFileIds(files);
18 }
19
20 const fileIdSet = new Set(files.map((file) => file.id));
21 return fileIds.filter((id) => fileIdSet.has(id));
22}
23
24export function getValidExplicitReviewFileIds(
25 files: ReadonlyArray<ReviewRenderableFile>,
26 fileIds: ReadonlyArray<string> | undefined,
27): ReadonlyArray<string> {
28 if (fileIds === undefined) {
29 return [];
30 }
31
32 return getValidReviewFileIds(files, fileIds);
33}
34
35export function toggleReviewFileId(
36 fileIds: ReadonlyArray<string>,
37 fileId: string,
38): ReadonlyArray<string> {
39 return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : [...fileIds, fileId];
40}
41
42export function removeReviewFileId(
43 fileIds: ReadonlyArray<string>,
44 fileId: string,
45): ReadonlyArray<string> {
46 return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : fileIds;
47}
48
49export function useReviewFileVisibility(input: {
50 readonly threadKey: string | null;
51 readonly sectionId: string | null;
52 readonly files: ReadonlyArray<ReviewRenderableFile>;
53 readonly cachedExpandedFileIds: ReadonlyArray<string> | undefined;
54 readonly cachedViewedFileIds: ReadonlyArray<string> | undefined;
55}) {
56 const { cachedExpandedFileIds, cachedViewedFileIds, files, sectionId, threadKey } = input;
57
58 const expandedFileIds = useMemo(
59 () => getValidReviewFileIds(files, cachedExpandedFileIds),
60 [cachedExpandedFileIds, files],
61 );
62 const viewedFileIds = useMemo(
63 () => getValidExplicitReviewFileIds(files, cachedViewedFileIds),
64 [cachedViewedFileIds, files],
65 );
66 const collapsedFileIds = useMemo(() => {
67 const expandedFileIdSet = new Set(expandedFileIds);
68 return files.reduce<string[]>((fileIds, file) => {
69 if (!expandedFileIdSet.has(file.id)) {
70 fileIds.push(file.id);
71 }
72 return fileIds;
73 }, []);
74 }, [expandedFileIds, files]);
75
76 const toggleExpandedFile = useCallback(
77 (fileId: string) => {
78 if (!threadKey || !sectionId) {
79 return;
80 }
81
82 updateReviewExpandedFileIds(threadKey, sectionId, (existing) =>
83 toggleReviewFileId(getValidReviewFileIds(files, existing), fileId),
84 );
85 },
86 [files, sectionId, threadKey],
87 );
88
89 const toggleViewedFile = useCallback(
90 (fileId: string) => {
91 if (!threadKey || !sectionId) {
92 return;
93 }
94
95 const shouldCollapse = !viewedFileIds.includes(fileId);
96 updateReviewViewedFileIds(threadKey, sectionId, (existing) =>
97 toggleReviewFileId(getValidExplicitReviewFileIds(files, existing), fileId),
98 );
99
100 if (shouldCollapse) {
101 updateReviewExpandedFileIds(threadKey, sectionId, (existing) =>
102 removeReviewFileId(getValidReviewFileIds(files, existing), fileId),
103 );
104 }
105 },
106 [files, sectionId, threadKey, viewedFileIds],
107 );
108
109 return {
110 expandedFileIds,
111 viewedFileIds,
112 collapsedFileIds,
113 toggleExpandedFile,
114 toggleViewedFile,
115 };
116}
apps/mobile/src/features/review/reviewHighlighterEngine.test.ts created+34
...@@ -0,0 +1,34 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 resolveReviewHighlighterEngine,
5 resolveReviewHighlighterEnginePreference,
6} from "./reviewHighlighterEngine";
7
8describe("resolveReviewHighlighterEnginePreference", () => {
9 it("defaults invalid values to native", () => {
10 expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("native");
11 expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("native");
12 });
13
14 it("accepts supported values", () => {
15 expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript");
16 expect(resolveReviewHighlighterEnginePreference("js")).toBe("javascript");
17 expect(resolveReviewHighlighterEnginePreference("native")).toBe("native");
18 });
19});
20
21describe("resolveReviewHighlighterEngine", () => {
22 it("uses javascript when explicitly requested", () => {
23 expect(resolveReviewHighlighterEngine("javascript", true)).toBe("javascript");
24 expect(resolveReviewHighlighterEngine("javascript", false)).toBe("javascript");
25 });
26
27 it("uses native when available for native preference", () => {
28 expect(resolveReviewHighlighterEngine("native", true)).toBe("native");
29 });
30
31 it("falls back to javascript when native is unavailable", () => {
32 expect(resolveReviewHighlighterEngine("native", false)).toBe("javascript");
33 });
34});
apps/mobile/src/features/review/reviewHighlighterEngine.ts created+31
...@@ -0,0 +1,31 @@
1export type ReviewHighlighterEnginePreference = "javascript" | "native";
2export type ReviewHighlighterEngine = "javascript" | "native";
3
4export function resolveReviewHighlighterEnginePreference(
5 value: string | undefined,
6): ReviewHighlighterEnginePreference {
7 switch (value) {
8 case "js":
9 case "javascript":
10 return "javascript";
11 case "native":
12 return "native";
13 default:
14 return "native";
15 }
16}
17
18export function resolveReviewHighlighterEngine(
19 preference: ReviewHighlighterEnginePreference,
20 nativeAvailable: boolean,
21): ReviewHighlighterEngine {
22 if (preference === "javascript") {
23 return "javascript";
24 }
25
26 if (nativeAvailable) {
27 return "native";
28 }
29
30 return "javascript";
31}
apps/mobile/src/features/review/reviewHighlighterState.test.ts created+75
...@@ -0,0 +1,75 @@
1import { assert, beforeEach, it } from "vitest";
2import { AtomRegistry } from "effect/unstable/reactivity";
3
4import {
5 createReviewHighlighterManager,
6 IDLE_REVIEW_HIGHLIGHTER_STATE,
7} from "./reviewHighlighterState";
8
9let registry = AtomRegistry.make();
10
11beforeEach(() => {
12 registry.dispose();
13 registry = AtomRegistry.make();
14});
15
16function flushAsyncWork(): Promise<void> {
17 return Promise.resolve().then(() => undefined);
18}
19
20it("initializes review highlighter state once", async () => {
21 let prepareCalls = 0;
22 let languageCalls = 0;
23 let engineCalls = 0;
24 const manager = createReviewHighlighterManager({
25 getRegistry: () => registry,
26 loader: {
27 prepare: async () => {
28 prepareCalls += 1;
29 },
30 prepareLanguages: async () => {
31 languageCalls += 1;
32 },
33 getEngine: async () => {
34 engineCalls += 1;
35 return "javascript";
36 },
37 },
38 });
39
40 assert.deepStrictEqual(manager.getSnapshot(), IDLE_REVIEW_HIGHLIGHTER_STATE);
41
42 await Promise.all([manager.initialize(), manager.initialize()]);
43 await manager.initialize();
44
45 assert.strictEqual(prepareCalls, 1);
46 assert.strictEqual(languageCalls, 1);
47 assert.strictEqual(engineCalls, 1);
48 assert.deepStrictEqual(manager.getSnapshot(), {
49 engine: "javascript",
50 error: null,
51 status: "ready",
52 });
53});
54
55it("stores initialization failures in atom state", async () => {
56 const manager = createReviewHighlighterManager({
57 getRegistry: () => registry,
58 loader: {
59 prepare: async () => {
60 throw new Error("load failed");
61 },
62 prepareLanguages: async () => undefined,
63 getEngine: async () => "javascript",
64 },
65 });
66
67 void manager.initialize();
68 await flushAsyncWork();
69
70 assert.deepStrictEqual(manager.getSnapshot(), {
71 engine: null,
72 error: "load failed",
73 status: "error",
74 });
75});
apps/mobile/src/features/review/reviewHighlighterState.ts created+154
...@@ -0,0 +1,154 @@
1import { useAtomValue } from "@effect/atom-react";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3import { useEffect } from "react";
4
5import { appAtomRegistry } from "../../state/atom-registry";
6import {
7 getActiveReviewHighlighterEngine,
8 prepareReviewHighlighter,
9 prepareReviewHighlighterLanguages,
10 type ReviewHighlighterEngine,
11} from "./shikiReviewHighlighter";
12
13export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error";
14
15export interface ReviewHighlighterState {
16 readonly engine: ReviewHighlighterEngine | null;
17 readonly error: string | null;
18 readonly status: ReviewHighlighterStatus;
19}
20
21export interface ReviewHighlighterLoader {
22 readonly prepare: () => Promise<void>;
23 readonly prepareLanguages: (languages: ReadonlyArray<string>) => Promise<void>;
24 readonly getEngine: () => Promise<ReviewHighlighterEngine>;
25}
26
27const REVIEW_INITIAL_LANGUAGES = [
28 "typescript",
29 "tsx",
30 "javascript",
31 "jsx",
32 "json",
33 "yaml",
34 "bash",
35] as const;
36
37export const IDLE_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({
38 engine: null,
39 error: null,
40 status: "idle",
41});
42
43const INITIALIZING_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({
44 engine: null,
45 error: null,
46 status: "initializing",
47});
48
49export const reviewHighlighterStateAtom = Atom.make(IDLE_REVIEW_HIGHLIGHTER_STATE).pipe(
50 Atom.keepAlive,
51 Atom.withLabel("mobile:review-highlighter"),
52);
53
54function isReviewHighlighterProviderDebugLoggingEnabled(): boolean {
55 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
56}
57
58function logReviewHighlighterProviderDiagnostic(
59 message: string,
60 details?: Record<string, unknown>,
61): void {
62 if (!isReviewHighlighterProviderDebugLoggingEnabled()) {
63 return;
64 }
65
66 if (details) {
67 console.log(`[review-highlighter-provider] ${message}`, details);
68 return;
69 }
70
71 console.log(`[review-highlighter-provider] ${message}`);
72}
73
74export function createReviewHighlighterManager(config: {
75 readonly getRegistry: () => AtomRegistry.AtomRegistry;
76 readonly loader: ReviewHighlighterLoader;
77 readonly languages?: ReadonlyArray<string>;
78}) {
79 let started = false;
80 let inFlight: Promise<void> | null = null;
81
82 function getSnapshot(): ReviewHighlighterState {
83 return config.getRegistry().get(reviewHighlighterStateAtom);
84 }
85
86 function setState(state: ReviewHighlighterState): void {
87 config.getRegistry().set(reviewHighlighterStateAtom, state);
88 }
89
90 function initialize(): Promise<void> {
91 if (inFlight) {
92 return inFlight;
93 }
94
95 if (started && getSnapshot().status === "ready") {
96 return Promise.resolve();
97 }
98
99 started = true;
100 setState(INITIALIZING_REVIEW_HIGHLIGHTER_STATE);
101
102 inFlight = (async () => {
103 const startedAt = performance.now();
104 try {
105 await config.loader.prepare();
106 await config.loader.prepareLanguages(config.languages ?? REVIEW_INITIAL_LANGUAGES);
107 const engine = await config.loader.getEngine();
108 const durationMs = Math.round(performance.now() - startedAt);
109 logReviewHighlighterProviderDiagnostic("initialized", {
110 durationMs,
111 engine,
112 });
113 setState({ engine, error: null, status: "ready" });
114 } catch (error) {
115 const message = error instanceof Error ? error.message : String(error);
116 logReviewHighlighterProviderDiagnostic("initialization failed", { error: message });
117 setState({ engine: null, error: message, status: "error" });
118 } finally {
119 inFlight = null;
120 }
121 })();
122
123 return inFlight;
124 }
125
126 function reset(): void {
127 started = false;
128 inFlight = null;
129 setState(IDLE_REVIEW_HIGHLIGHTER_STATE);
130 }
131
132 return {
133 getSnapshot,
134 initialize,
135 reset,
136 };
137}
138
139const reviewHighlighterManager = createReviewHighlighterManager({
140 getRegistry: () => appAtomRegistry,
141 loader: {
142 prepare: prepareReviewHighlighter,
143 prepareLanguages: prepareReviewHighlighterLanguages,
144 getEngine: getActiveReviewHighlighterEngine,
145 },
146});
147
148export function useReviewHighlighterState(): ReviewHighlighterState {
149 useEffect(() => {
150 void reviewHighlighterManager.initialize();
151 }, []);
152
153 return useAtomValue(reviewHighlighterStateAtom);
154}
apps/mobile/src/features/review/reviewModel.test.ts created+354
...@@ -0,0 +1,354 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 MessageId,
5 TurnId,
6 type OrchestrationCheckpointSummary,
7 type ReviewDiffPreviewSource,
8} from "@t3tools/contracts";
9
10import {
11 buildReviewListItems,
12 buildReviewParsedDiff,
13 buildReviewSectionItems,
14 getDefaultReviewSectionId,
15 getReviewFilePreviewState,
16 getReviewSectionIdForCheckpoint,
17 type ReviewRenderableFile,
18} from "./reviewModel";
19
20function makeCheckpoint(
21 input: Partial<OrchestrationCheckpointSummary> &
22 Pick<OrchestrationCheckpointSummary, "turnId" | "checkpointTurnCount" | "completedAt">,
23): OrchestrationCheckpointSummary {
24 return {
25 checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any,
26 status: "ready",
27 files: [],
28 assistantMessageId: MessageId.make(`msg-${input.checkpointTurnCount}`),
29 ...input,
30 };
31}
32
33function makeRenderableFile(
34 input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">,
35): ReviewRenderableFile {
36 return {
37 id: input.path,
38 cacheKey: input.path,
39 previousPath: null,
40 changeType: "new",
41 additions: 0,
42 deletions: 0,
43 languageHint: null,
44 additionLines: [],
45 deletionLines: [],
46 rows: [],
47 ...input,
48 };
49}
50
51describe("buildReviewSectionItems", () => {
52 it("keeps one chip per checkpoint and appends git sources", () => {
53 const checkpoints = [
54 makeCheckpoint({
55 turnId: TurnId.make("turn-1"),
56 checkpointTurnCount: 1,
57 completedAt: "2026-04-01T00:00:00.000Z",
58 }),
59 makeCheckpoint({
60 turnId: TurnId.make("turn-2"),
61 checkpointTurnCount: 2,
62 completedAt: "2026-04-02T00:00:00.000Z",
63 }),
64 ];
65 const gitSections: ReviewDiffPreviewSource[] = [
66 {
67 id: "working-tree",
68 kind: "working-tree",
69 title: "Dirty worktree",
70 baseRef: "HEAD",
71 headRef: null,
72 diff: "diff --git a/a.ts b/a.ts",
73 diffHash: "hash-dirty",
74 truncated: false,
75 },
76 {
77 id: "branch-range",
78 kind: "branch-range",
79 title: "Against main",
80 baseRef: "main",
81 headRef: "feature",
82 diff: "diff --git a/a.ts b/a.ts",
83 diffHash: "hash-base",
84 truncated: false,
85 },
86 ];
87
88 const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]);
89 const items = buildReviewSectionItems({
90 checkpoints,
91 gitSections,
92 turnDiffById: {
93 [loadedTurnId]: "diff --git a/loaded.ts b/loaded.ts",
94 },
95 loadingTurnIds: {
96 [getReviewSectionIdForCheckpoint(checkpoints[1])]: true,
97 },
98 loadingGitSections: false,
99 });
100
101 expect(items.map((item) => item.id)).toEqual([
102 "turn:2",
103 "turn:1",
104 "git:working-tree",
105 "git:branch-range",
106 ]);
107 expect(items[0]).toMatchObject({ isLoading: true, diff: null });
108 expect(items[1]).toMatchObject({
109 isLoading: false,
110 diff: expect.stringContaining("loaded.ts"),
111 });
112 expect(getDefaultReviewSectionId(items)).toBe("turn:2");
113 });
114
115 it("shows dirty worktree while git preview is loading", () => {
116 const items = buildReviewSectionItems({
117 checkpoints: [],
118 gitSections: [],
119 turnDiffById: {},
120 loadingTurnIds: {},
121 loadingGitSections: true,
122 });
123
124 expect(items).toEqual([
125 expect.objectContaining({
126 id: "git:working-tree",
127 kind: "working-tree",
128 title: "Dirty worktree",
129 subtitle: "Tracked, staged, and untracked worktree changes",
130 diff: null,
131 isLoading: true,
132 }),
133 ]);
134 expect(getDefaultReviewSectionId(items)).toBe("git:working-tree");
135 });
136});
137
138describe("buildReviewParsedDiff", () => {
139 it("builds renderable rows from a unified patch", () => {
140 const parsed = buildReviewParsedDiff(
141 [
142 "diff --git a/apps/mobile/src/a.ts b/apps/mobile/src/a.ts",
143 "index 1111111..2222222 100644",
144 "--- a/apps/mobile/src/a.ts",
145 "+++ b/apps/mobile/src/a.ts",
146 "@@ -1,2 +1,3 @@",
147 "-const before = 1;",
148 "+const after = 2;",
149 "+console.log(after);",
150 " return true;",
151 ].join("\n"),
152 "unit",
153 );
154
155 expect(parsed.kind).toBe("files");
156 if (parsed.kind !== "files") {
157 return;
158 }
159
160 expect(parsed.fileCount).toBe(1);
161 expect(parsed.additions).toBe(2);
162 expect(parsed.deletions).toBe(1);
163 expect(parsed.files[0]).toMatchObject({
164 path: "apps/mobile/src/a.ts",
165 additions: 2,
166 deletions: 1,
167 });
168 expect(parsed.files[0]?.rows).toEqual([
169 expect.objectContaining({ kind: "hunk", header: "@@ -1,2 +1,3 @@" }),
170 expect.objectContaining({
171 kind: "line",
172 change: "delete",
173 oldLineNumber: 1,
174 newLineNumber: null,
175 content: "const before = 1;",
176 comparison: { change: "add", tokenIndex: 0 },
177 }),
178 expect.objectContaining({
179 kind: "line",
180 change: "add",
181 oldLineNumber: null,
182 newLineNumber: 1,
183 content: "const after = 2;",
184 comparison: { change: "delete", tokenIndex: 0 },
185 }),
186 expect.objectContaining({
187 kind: "line",
188 change: "add",
189 oldLineNumber: null,
190 newLineNumber: 2,
191 content: "console.log(after);",
192 comparison: null,
193 }),
194 expect.objectContaining({
195 kind: "line",
196 change: "context",
197 oldLineNumber: 2,
198 newLineNumber: 3,
199 content: "return true;",
200 comparison: null,
201 }),
202 ]);
203 });
204
205 it("treats truncated patches as partial diffs instead of failing", () => {
206 const parsed = buildReviewParsedDiff(
207 [
208 "diff --git a/apps/mobile/src/a.ts b/apps/mobile/src/a.ts",
209 "index 1111111..2222222 100644",
210 "--- a/apps/mobile/src/a.ts",
211 "+++ b/apps/mobile/src/a.ts",
212 "@@ -1 +1,2 @@",
213 " const before = 1;",
214 "+const after = 2;",
215 "",
216 "[truncated]",
217 ].join("\n"),
218 "unit-truncated",
219 );
220
221 expect(parsed.kind).toBe("files");
222 if (parsed.kind !== "files") {
223 return;
224 }
225
226 expect(parsed.notice).toContain("server size cap");
227 expect(parsed.fileCount).toBe(1);
228 expect(parsed.files[0]?.rows[0]).toMatchObject({
229 kind: "hunk",
230 header: "@@ -1,1 +1,2 @@",
231 });
232 });
233
234 it("suppresses preview for non-text file formats", () => {
235 const preview = getReviewFilePreviewState(
236 makeRenderableFile({
237 path: "apps/mobile/assets/icon.png",
238 }),
239 );
240
241 expect(preview).toMatchObject({
242 kind: "suppressed",
243 reason: "non-text",
244 title: "Non-text file",
245 actionLabel: null,
246 });
247 });
248
249 it("suppresses large diffs until explicitly requested", () => {
250 const preview = getReviewFilePreviewState(
251 makeRenderableFile({
252 path: "apps/mobile/src/big.ts",
253 rows: Array.from({ length: 401 }, (_, index) => ({
254 kind: "line" as const,
255 id: `line-${index}`,
256 change: "add" as const,
257 oldLineNumber: null,
258 newLineNumber: index + 1,
259 content: `const line${index} = ${index};`,
260 additionTokenIndex: index,
261 deletionTokenIndex: null,
262 comparison: null,
263 })),
264 }),
265 );
266
267 expect(preview).toMatchObject({
268 kind: "suppressed",
269 reason: "large",
270 title: "Large diff",
271 actionLabel: "Load diff",
272 });
273 });
274
275 it("flattens expanded file rows into virtualized review items", () => {
276 const file = makeRenderableFile({
277 path: "apps/mobile/src/a.ts",
278 rows: [
279 {
280 kind: "hunk",
281 id: "hunk-1",
282 header: "@@ -1,1 +1,2 @@",
283 context: null,
284 },
285 {
286 kind: "line",
287 id: "line-1",
288 change: "add",
289 oldLineNumber: null,
290 newLineNumber: 1,
291 content: "const after = 2;",
292 additionTokenIndex: 0,
293 deletionTokenIndex: null,
294 comparison: null,
295 },
296 ],
297 });
298
299 const items = buildReviewListItems({
300 files: [file],
301 expandedFileIds: [file.id],
302 revealedLargeFileIds: [],
303 });
304
305 expect(items).toEqual([
306 expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }),
307 expect.objectContaining({
308 kind: "hunk",
309 fileId: file.id,
310 file,
311 row: file.rows[0],
312 }),
313 expect.objectContaining({
314 kind: "line",
315 fileId: file.id,
316 file,
317 row: file.rows[1],
318 lineIndex: 0,
319 }),
320 ]);
321 });
322
323 it("keeps large diffs collapsed into a placeholder item until revealed", () => {
324 const file = makeRenderableFile({
325 path: "apps/mobile/src/big.ts",
326 rows: Array.from({ length: 401 }, (_, index) => ({
327 kind: "line" as const,
328 id: `line-${index}`,
329 change: "add" as const,
330 oldLineNumber: null,
331 newLineNumber: index + 1,
332 content: `const line${index} = ${index};`,
333 additionTokenIndex: index,
334 deletionTokenIndex: null,
335 comparison: null,
336 })),
337 });
338
339 const items = buildReviewListItems({
340 files: [file],
341 expandedFileIds: [file.id],
342 revealedLargeFileIds: [],
343 });
344
345 expect(items).toEqual([
346 expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }),
347 expect.objectContaining({
348 kind: "file-suppressed",
349 fileId: file.id,
350 actionLabel: "Load diff",
351 }),
352 ]);
353 });
354});
apps/mobile/src/features/review/reviewModel.ts created+639
...@@ -0,0 +1,639 @@
1import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
2import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
3import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
4import * as Arr from "effect/Array";
5import { pipe } from "effect/Function";
6import * as Order from "effect/Order";
7
8export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";
9
10const DIRTY_WORKTREE_SECTION_ID = "git:working-tree";
11const DIRTY_WORKTREE_TITLE = "Dirty worktree";
12const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes";
13
14export interface ReviewSectionItem {
15 readonly id: string;
16 readonly kind: ReviewSectionKind;
17 readonly title: string;
18 readonly subtitle: string | null;
19 readonly diff: string | null;
20 readonly isLoading: boolean;
21}
22
23export interface ReviewRenderableHunkRow {
24 readonly kind: "hunk";
25 readonly id: string;
26 readonly header: string;
27 readonly context: string | null;
28}
29
30export interface ReviewRenderableLineRow {
31 readonly kind: "line";
32 readonly id: string;
33 readonly change: "context" | "add" | "delete";
34 readonly oldLineNumber: number | null;
35 readonly newLineNumber: number | null;
36 readonly content: string;
37 readonly additionTokenIndex: number | null;
38 readonly deletionTokenIndex: number | null;
39 readonly comparison: {
40 readonly change: "add" | "delete";
41 readonly tokenIndex: number;
42 } | null;
43}
44
45export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow;
46
47export interface ReviewRenderableFile {
48 readonly id: string;
49 readonly cacheKey: string;
50 readonly path: string;
51 readonly previousPath: string | null;
52 readonly changeType: ChangeTypes;
53 readonly additions: number;
54 readonly deletions: number;
55 readonly languageHint: string | null;
56 readonly additionLines: ReadonlyArray<string>;
57 readonly deletionLines: ReadonlyArray<string>;
58 readonly rows: ReadonlyArray<ReviewRenderableRow>;
59}
60
61export interface ReviewFileHeaderListItem {
62 readonly kind: "file-header";
63 readonly id: string;
64 readonly fileId: string;
65 readonly file: ReviewRenderableFile;
66 readonly expanded: boolean;
67}
68
69export interface ReviewFileSuppressedListItem {
70 readonly kind: "file-suppressed";
71 readonly id: string;
72 readonly fileId: string;
73 readonly message: string;
74 readonly actionLabel: string | null;
75}
76
77export interface ReviewHunkListItem {
78 readonly kind: "hunk";
79 readonly id: string;
80 readonly fileId: string;
81 readonly file: ReviewRenderableFile;
82 readonly row: ReviewRenderableHunkRow;
83}
84
85export interface ReviewLineListItem {
86 readonly kind: "line";
87 readonly id: string;
88 readonly fileId: string;
89 readonly file: ReviewRenderableFile;
90 readonly row: ReviewRenderableLineRow;
91 readonly lineIndex: number;
92}
93
94export type ReviewListItem =
95 | ReviewFileHeaderListItem
96 | ReviewFileSuppressedListItem
97 | ReviewHunkListItem
98 | ReviewLineListItem;
99
100export type ReviewFilePreviewState =
101 | {
102 readonly kind: "render";
103 }
104 | {
105 readonly kind: "suppressed";
106 readonly reason: "non-text" | "large";
107 readonly title: string;
108 readonly message: string;
109 readonly actionLabel: string | null;
110 };
111
112export type ReviewParsedDiff =
113 | {
114 readonly kind: "empty";
115 }
116 | {
117 readonly kind: "raw";
118 readonly text: string;
119 readonly reason: string;
120 readonly notice: string | null;
121 }
122 | {
123 readonly kind: "files";
124 readonly files: ReadonlyArray<ReviewRenderableFile>;
125 readonly fileCount: number;
126 readonly additions: number;
127 readonly deletions: number;
128 readonly notice: string | null;
129 };
130
131function checkpointTitle(checkpoint: OrchestrationCheckpointSummary): string {
132 return `Turn ${checkpoint.checkpointTurnCount}`;
133}
134
135function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string {
136 const fileCount = checkpoint.files.length;
137 if (checkpoint.status !== "ready") {
138 return `Diff ${checkpoint.status}`;
139 }
140 return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`;
141}
142
143function compareCheckpointTurnCountDescending(
144 left: OrchestrationCheckpointSummary,
145 right: OrchestrationCheckpointSummary,
146): -1 | 0 | 1 {
147 if (left.checkpointTurnCount === right.checkpointTurnCount) {
148 return 0;
149 }
150
151 return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1;
152}
153
154const readyCheckpointOrder = Order.make<OrchestrationCheckpointSummary>(
155 compareCheckpointTurnCountDescending,
156);
157
158function gitSubtitle(section: ReviewDiffPreviewSource): string | null {
159 if (section.kind === "working-tree") {
160 return DIRTY_WORKTREE_SUBTITLE;
161 }
162 if (section.baseRef) {
163 return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`;
164 }
165 return "Base branch unavailable";
166}
167
168function stripGitPrefix(pathValue: string | undefined): string | null {
169 if (!pathValue) {
170 return null;
171 }
172 if (pathValue.startsWith("a/") || pathValue.startsWith("b/")) {
173 return pathValue.slice(2);
174 }
175 return pathValue;
176}
177
178function stripTrailingNewline(value: string): string {
179 return value.endsWith("\n") ? value.slice(0, -1) : value;
180}
181
182function splitTruncationMarker(diff: string): {
183 readonly text: string;
184 readonly truncated: boolean;
185} {
186 const trimmed = diff.trimEnd();
187 if (!trimmed.endsWith("[truncated]")) {
188 return { text: trimmed, truncated: false };
189 }
190
191 return {
192 text: trimmed.replace(/\n*\[truncated\]\s*$/, "").trimEnd(),
193 truncated: true,
194 };
195}
196
197function runDiffParserSilently<T>(callback: () => T): T {
198 const originalConsoleError = console.error;
199 console.error = () => undefined;
200 try {
201 return callback();
202 } finally {
203 console.error = originalConsoleError;
204 }
205}
206
207const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
208const FNV_PRIME_32 = 0x01000193;
209const SECONDARY_HASH_SEED = 0x9e3779b9;
210const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;
211const LARGE_DIFF_LINE_THRESHOLD = 400;
212const LARGE_DIFF_CHARACTER_THRESHOLD = 24_000;
213const NON_TEXT_FILE_EXTENSIONS = new Set([
214 "png",
215 "jpg",
216 "jpeg",
217 "gif",
218 "webp",
219 "bmp",
220 "ico",
221 "icns",
222 "avif",
223 "heic",
224 "tif",
225 "tiff",
226 "mp3",
227 "wav",
228 "flac",
229 "ogg",
230 "m4a",
231 "aac",
232 "mp4",
233 "mov",
234 "avi",
235 "mkv",
236 "webm",
237 "pdf",
238 "zip",
239 "gz",
240 "tgz",
241 "bz2",
242 "7z",
243 "rar",
244 "woff",
245 "woff2",
246 "ttf",
247 "otf",
248 "eot",
249 "wasm",
250 "exe",
251 "dll",
252 "so",
253 "dylib",
254]);
255
256function fnv1a32(input: string, seed: number, multiplier: number): number {
257 let hash = seed >>> 0;
258 for (let index = 0; index < input.length; index += 1) {
259 hash ^= input.charCodeAt(index);
260 hash = Math.imul(hash, multiplier) >>> 0;
261 }
262 return hash >>> 0;
263}
264
265function buildPatchCacheKey(patch: string, scope: string): string {
266 const normalizedPatch = patch.trim();
267 const primary = fnv1a32(normalizedPatch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36);
268 const secondary = fnv1a32(
269 normalizedPatch,
270 SECONDARY_HASH_SEED,
271 SECONDARY_HASH_MULTIPLIER,
272 ).toString(36);
273 return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;
274}
275
276function getFileExtension(path: string): string | null {
277 const match = /\.([a-z0-9]+)$/i.exec(path);
278 return match?.[1]?.toLowerCase() ?? null;
279}
280
281function countReviewRenderableLineRows(file: ReviewRenderableFile): number {
282 return file.rows.reduce((total, row) => total + (row.kind === "line" ? 1 : 0), 0);
283}
284
285function countReviewRenderableCharacters(file: ReviewRenderableFile): number {
286 return file.rows.reduce(
287 (total, row) => total + (row.kind === "line" ? row.content.length : row.header.length),
288 0,
289 );
290}
291
292export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFilePreviewState {
293 const extension = getFileExtension(file.path);
294 if (extension && NON_TEXT_FILE_EXTENSIONS.has(extension)) {
295 return {
296 kind: "suppressed",
297 reason: "non-text",
298 title: "Non-text file",
299 message: "Diff preview is not available for this file format.",
300 actionLabel: null,
301 };
302 }
303
304 const lineCount = countReviewRenderableLineRows(file);
305 const characterCount = countReviewRenderableCharacters(file);
306 if (lineCount > LARGE_DIFF_LINE_THRESHOLD || characterCount > LARGE_DIFF_CHARACTER_THRESHOLD) {
307 return {
308 kind: "suppressed",
309 reason: "large",
310 title: "Large diff",
311 message: "Large diffs are not rendered by default.",
312 actionLabel: "Load diff",
313 };
314 }
315
316 return { kind: "render" };
317}
318
319// The flattened review list item model is inspired by pierre/diffs' iterator-first
320// virtualization architecture, adapted here for React Native virtualization.
321// Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs
322// Reference files:
323// - src/utils/iterateOverDiff.ts
324// - src/components/VirtualizedFileDiff.ts
325export function buildReviewListItems(input: {
326 readonly files: ReadonlyArray<ReviewRenderableFile>;
327 readonly expandedFileIds: ReadonlyArray<string>;
328 readonly revealedLargeFileIds: ReadonlyArray<string>;
329}): ReadonlyArray<ReviewListItem> {
330 const expandedFileIds = new Set(input.expandedFileIds);
331 const revealedLargeFileIds = new Set(input.revealedLargeFileIds);
332 const items: ReviewListItem[] = [];
333
334 input.files.forEach((file) => {
335 const expanded = expandedFileIds.has(file.id);
336 items.push({
337 kind: "file-header",
338 id: `${file.id}:header`,
339 fileId: file.id,
340 file,
341 expanded,
342 });
343
344 if (!expanded) {
345 return;
346 }
347
348 const previewState = getReviewFilePreviewState(file);
349 if (previewState.kind === "suppressed") {
350 if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) {
351 items.push({
352 kind: "file-suppressed",
353 id: `${file.id}:suppressed`,
354 fileId: file.id,
355 message: previewState.message,
356 actionLabel: previewState.actionLabel,
357 });
358 return;
359 }
360 }
361
362 let lineIndex = 0;
363 file.rows.forEach((row, rowIndex) => {
364 if (row.kind === "hunk") {
365 items.push({
366 kind: "hunk",
367 id: `${file.id}:row:${rowIndex}:${row.id}`,
368 fileId: file.id,
369 file,
370 row,
371 });
372 return;
373 }
374
375 items.push({
376 kind: "line",
377 id: `${file.id}:row:${rowIndex}:${row.id}`,
378 fileId: file.id,
379 file,
380 row,
381 lineIndex,
382 });
383 lineIndex += 1;
384 });
385 });
386
387 return items;
388}
389
390function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {
391 return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
392}
393
394function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRenderableRow> {
395 const rows: ReviewRenderableRow[] = [];
396 let rowIndex = 0;
397
398 file.hunks.forEach((hunk, hunkIndex) => {
399 rows.push({
400 kind: "hunk",
401 id: `${file.cacheKey ?? file.name}:hunk:${hunkIndex}`,
402 header: fallbackHunkHeader(hunk),
403 context: hunk.hunkContext ? stripTrailingNewline(hunk.hunkContext) : null,
404 });
405
406 let deletionLineNumber = hunk.deletionStart;
407 let additionLineNumber = hunk.additionStart;
408 let deletionTokenIndex = hunk.deletionLineIndex;
409 let additionTokenIndex = hunk.additionLineIndex;
410
411 hunk.hunkContent.forEach((segment) => {
412 if (segment.type === "context") {
413 for (let index = 0; index < segment.lines; index += 1) {
414 rows.push({
415 kind: "line",
416 id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`,
417 change: "context",
418 oldLineNumber: deletionLineNumber,
419 newLineNumber: additionLineNumber,
420 content: stripTrailingNewline(
421 file.additionLines[additionTokenIndex] ??
422 file.deletionLines[deletionTokenIndex] ??
423 "",
424 ),
425 additionTokenIndex,
426 deletionTokenIndex,
427 comparison: null,
428 });
429 deletionLineNumber += 1;
430 additionLineNumber += 1;
431 deletionTokenIndex += 1;
432 additionTokenIndex += 1;
433 }
434 return;
435 }
436
437 const pairedLineCount = Math.min(segment.deletions, segment.additions);
438 const deletionTokenIndexStart = deletionTokenIndex;
439 const additionTokenIndexStart = additionTokenIndex;
440
441 for (let index = 0; index < segment.deletions; index += 1) {
442 rows.push({
443 kind: "line",
444 id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`,
445 change: "delete",
446 oldLineNumber: deletionLineNumber,
447 newLineNumber: null,
448 content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""),
449 additionTokenIndex: null,
450 deletionTokenIndex,
451 comparison:
452 index < pairedLineCount
453 ? {
454 change: "add",
455 tokenIndex: additionTokenIndexStart + index,
456 }
457 : null,
458 });
459 deletionLineNumber += 1;
460 deletionTokenIndex += 1;
461 }
462
463 for (let index = 0; index < segment.additions; index += 1) {
464 rows.push({
465 kind: "line",
466 id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`,
467 change: "add",
468 oldLineNumber: null,
469 newLineNumber: additionLineNumber,
470 content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""),
471 additionTokenIndex,
472 deletionTokenIndex: null,
473 comparison:
474 index < pairedLineCount
475 ? {
476 change: "delete",
477 tokenIndex: deletionTokenIndexStart + index,
478 }
479 : null,
480 });
481 additionLineNumber += 1;
482 additionTokenIndex += 1;
483 }
484 });
485 });
486
487 return rows;
488}
489
490function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile {
491 const path = stripGitPrefix(file.name) ?? stripGitPrefix(file.prevName) ?? file.name;
492 const previousPath = stripGitPrefix(file.prevName);
493 const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0);
494 const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0);
495 const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`;
496
497 return {
498 id: cacheKey,
499 cacheKey,
500 path,
501 previousPath,
502 changeType: file.type,
503 additions,
504 deletions,
505 languageHint: file.lang ?? null,
506 additionLines: file.additionLines,
507 deletionLines: file.deletionLines,
508 rows: buildRenderableRows(file),
509 };
510}
511
512export function getReviewSectionIdForCheckpoint(
513 checkpoint: Pick<OrchestrationCheckpointSummary, "checkpointTurnCount">,
514): string {
515 return `turn:${checkpoint.checkpointTurnCount}`;
516}
517
518export function getReadyReviewCheckpoints(
519 checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
520): ReadonlyArray<OrchestrationCheckpointSummary> {
521 return pipe(
522 checkpoints,
523 Arr.filter((checkpoint) => checkpoint.status === "ready"),
524 Arr.sort(readyCheckpointOrder),
525 );
526}
527
528export function buildReviewSectionItems(input: {
529 readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>;
530 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;
531 readonly turnDiffById: Readonly<Record<string, string | undefined>>;
532 readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>;
533 readonly loadingGitSections: boolean;
534}): ReadonlyArray<ReviewSectionItem> {
535 const turnItems = getReadyReviewCheckpoints(input.checkpoints).map<ReviewSectionItem>(
536 (checkpoint) => {
537 const id = getReviewSectionIdForCheckpoint(checkpoint);
538 return {
539 id,
540 kind: "turn",
541 title: checkpointTitle(checkpoint),
542 subtitle: checkpointSubtitle(checkpoint),
543 diff: input.turnDiffById[id] ?? null,
544 isLoading: input.loadingTurnIds[id] === true,
545 };
546 },
547 );
548
549 const gitItems = input.gitSections.map<ReviewSectionItem>((section) => ({
550 id: `git:${section.kind}`,
551 kind: section.kind,
552 title: section.title,
553 subtitle: gitSubtitle(section),
554 diff: section.diff,
555 isLoading: false,
556 }));
557 const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID);
558 const visibleGitItems =
559 input.loadingGitSections && !hasDirtyWorktreeItem
560 ? [
561 {
562 id: DIRTY_WORKTREE_SECTION_ID,
563 kind: "working-tree",
564 title: DIRTY_WORKTREE_TITLE,
565 subtitle: DIRTY_WORKTREE_SUBTITLE,
566 diff: null,
567 isLoading: true,
568 } satisfies ReviewSectionItem,
569 ...gitItems,
570 ]
571 : gitItems;
572
573 return [...turnItems, ...visibleGitItems];
574}
575
576export function getDefaultReviewSectionId(
577 sections: ReadonlyArray<ReviewSectionItem>,
578): string | null {
579 return sections[0]?.id ?? null;
580}
581
582export function buildReviewParsedDiff(
583 diff: string | null | undefined,
584 cacheScope: string,
585): ReviewParsedDiff {
586 const normalized = diff?.trim();
587 if (!normalized) {
588 return { kind: "empty" };
589 }
590
591 const { text, truncated } = splitTruncationMarker(normalized);
592 if (text.length === 0) {
593 return { kind: "empty" };
594 }
595
596 const notice = truncated
597 ? "Diff output hit the server size cap. Showing the available excerpt."
598 : null;
599
600 try {
601 const parsedPatches = runDiffParserSilently(() =>
602 parsePatchFiles(text, buildPatchCacheKey(text, cacheScope)),
603 );
604 const files = pipe(
605 parsedPatches,
606 Arr.flatMap((patch) => patch.files),
607 Arr.map(mapRenderableFile),
608 );
609
610 if (files.length === 0) {
611 return {
612 kind: "raw",
613 text,
614 reason: truncated
615 ? "Diff was truncated before it could be parsed completely. Showing the raw excerpt."
616 : "Unsupported diff format. Showing raw patch.",
617 notice,
618 };
619 }
620
621 return {
622 kind: "files",
623 files,
624 fileCount: files.length,
625 additions: files.reduce((total, file) => total + file.additions, 0),
626 deletions: files.reduce((total, file) => total + file.deletions, 0),
627 notice,
628 };
629 } catch {
630 return {
631 kind: "raw",
632 text,
633 reason: truncated
634 ? "Diff was truncated before it could be parsed completely. Showing the raw excerpt."
635 : "Failed to parse patch. Showing raw patch.",
636 notice,
637 };
638 }
639}
apps/mobile/src/features/review/reviewPerf.ts created+83
...@@ -0,0 +1,83 @@
1interface ReviewPerformanceLike {
2 readonly now?: () => number;
3 readonly mark?: (name: string) => void;
4 readonly measure?: (name: string, startMark: string, endMark: string) => void;
5 readonly clearMarks?: (name?: string) => void;
6 readonly clearMeasures?: (name?: string) => void;
7}
8
9const REVIEW_PERF_PREFIX = "t3.review";
10let reviewPerfSequence = 0;
11
12function getPerformance(): ReviewPerformanceLike | null {
13 const candidate = (globalThis as { readonly performance?: ReviewPerformanceLike }).performance;
14 return candidate ?? null;
15}
16
17export function isReviewPerfEnabled(): boolean {
18 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
19}
20
21export function measureReviewWork<T>(name: string, callback: () => T): T {
22 if (!isReviewPerfEnabled()) {
23 return callback();
24 }
25
26 const perf = getPerformance();
27 const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`;
28 const startMark = `${marker}.start`;
29 const endMark = `${marker}.end`;
30 const startedAt = perf?.now?.() ?? Date.now();
31
32 perf?.mark?.(startMark);
33 try {
34 return callback();
35 } finally {
36 const durationMs = (perf?.now?.() ?? Date.now()) - startedAt;
37 perf?.mark?.(endMark);
38 perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark);
39 perf?.clearMarks?.(startMark);
40 perf?.clearMarks?.(endMark);
41 console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) });
42 }
43}
44
45export async function measureReviewAsyncWork<T>(
46 name: string,
47 callback: () => Promise<T>,
48): Promise<T> {
49 if (!isReviewPerfEnabled()) {
50 return callback();
51 }
52
53 const perf = getPerformance();
54 const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`;
55 const startMark = `${marker}.start`;
56 const endMark = `${marker}.end`;
57 const startedAt = perf?.now?.() ?? Date.now();
58
59 perf?.mark?.(startMark);
60 try {
61 return await callback();
62 } finally {
63 const durationMs = (perf?.now?.() ?? Date.now()) - startedAt;
64 perf?.mark?.(endMark);
65 perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark);
66 perf?.clearMarks?.(startMark);
67 perf?.clearMarks?.(endMark);
68 console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) });
69 }
70}
71
72export function markReviewEvent(name: string, details?: Record<string, unknown>): void {
73 if (!isReviewPerfEnabled()) {
74 return;
75 }
76
77 getPerformance()?.mark?.(`${REVIEW_PERF_PREFIX}.${name}`);
78 if (details) {
79 console.log(`[review-perf] ${name}`, details);
80 return;
81 }
82 console.log(`[review-perf] ${name}`);
83}
apps/mobile/src/features/review/reviewState.test.ts created+27
...@@ -0,0 +1,27 @@
1import { assert, it } from "vitest";
2
3import {
4 getReviewAsyncStateSnapshot,
5 setReviewAsyncError,
6 setReviewTurnDiffLoading,
7} from "./reviewState";
8
9it("stores review async loading and error state in atoms", () => {
10 const threadKey = `env-local:thread-review-state-${Date.now()}`;
11
12 setReviewTurnDiffLoading(threadKey, "turn-1", true);
13 setReviewAsyncError(threadKey, "load failed");
14
15 assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), {
16 loadingTurnIds: { "turn-1": true },
17 error: "load failed",
18 });
19
20 setReviewTurnDiffLoading(threadKey, "turn-1", false);
21 setReviewAsyncError(threadKey, null);
22
23 assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), {
24 loadingTurnIds: {},
25 error: null,
26 });
27});
apps/mobile/src/features/review/reviewState.ts created+296
...@@ -0,0 +1,296 @@
1import { useAtomValue } from "@effect/atom-react";
2
3import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts";
4import { Atom } from "effect/unstable/reactivity";
5
6import { scopedThreadKey } from "../../lib/scopedEntities";
7import { appAtomRegistry } from "../../state/atom-registry";
8import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel";
9
10const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewSource>>([]);
11const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({});
12const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze<Readonly<Record<string, boolean>>>({});
13const EMPTY_REVIEW_ASYNC_STATE = Object.freeze<ReviewAsyncState>({
14 loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS,
15 error: null,
16});
17const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze<
18 Readonly<Record<string, ReadonlyArray<string> | undefined>>
19>({});
20const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe(
21 Atom.keepAlive,
22 Atom.withLabel("mobile:review:git-sections:null"),
23);
24const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe(
25 Atom.keepAlive,
26 Atom.withLabel("mobile:review:turn-diffs:null"),
27);
28const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make<string | null>(null).pipe(
29 Atom.keepAlive,
30 Atom.withLabel("mobile:review:selected-section-id:null"),
31);
32const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe(
33 Atom.keepAlive,
34 Atom.withLabel("mobile:review:async-state:null"),
35);
36const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
37 Atom.keepAlive,
38 Atom.withLabel("mobile:review:section-file-ids:null"),
39);
40
41const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) =>
42 Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe(
43 Atom.keepAlive,
44 Atom.withLabel(`mobile:review:git-sections:${threadKey}`),
45 ),
46);
47
48const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) =>
49 Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe(
50 Atom.keepAlive,
51 Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`),
52 ),
53);
54
55const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) =>
56 Atom.make<string | null>(null).pipe(
57 Atom.keepAlive,
58 Atom.withLabel(`mobile:review:selected-section-id:${threadKey}`),
59 ),
60);
61
62const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) =>
63 Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe(
64 Atom.keepAlive,
65 Atom.withLabel(`mobile:review:async-state:${threadKey}`),
66 ),
67);
68
69const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
70 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
71 Atom.keepAlive,
72 Atom.withLabel(`mobile:review:expanded-file-ids:${threadKey}`),
73 ),
74);
75
76const reviewRevealedLargeFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
77 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
78 Atom.keepAlive,
79 Atom.withLabel(`mobile:review:revealed-large-file-ids:${threadKey}`),
80 ),
81);
82
83const reviewViewedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
84 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
85 Atom.keepAlive,
86 Atom.withLabel(`mobile:review:viewed-file-ids:${threadKey}`),
87 ),
88);
89
90const reviewParsedDiffBySectionCacheKeyAtom = Atom.family((cacheKey: string) =>
91 Atom.make<{ readonly diff: string | null; readonly parsed: ReviewParsedDiff } | null>(null).pipe(
92 Atom.keepAlive,
93 Atom.withLabel(`mobile:review:parsed-diffs:${cacheKey}`),
94 ),
95);
96
97export interface ReviewCacheForThread {
98 readonly threadKey: string | null;
99 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;
100 readonly turnDiffById: Readonly<Record<string, string>>;
101 readonly selectedSectionId: string | null;
102 readonly asyncState: ReviewAsyncState;
103 readonly expandedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;
104 readonly revealedLargeFileIdsBySection: Readonly<
105 Record<string, ReadonlyArray<string> | undefined>
106 >;
107 readonly viewedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;
108}
109
110export interface ReviewAsyncState {
111 readonly loadingTurnIds: Readonly<Record<string, boolean>>;
112 readonly error: string | null;
113}
114
115function buildThreadKey(input: {
116 readonly environmentId?: EnvironmentId;
117 readonly threadId?: ThreadId;
118}): string | null {
119 return input.environmentId && input.threadId
120 ? scopedThreadKey(input.environmentId, input.threadId)
121 : null;
122}
123
124function buildSectionCacheKey(threadKey: string, sectionId: string): string {
125 return `${threadKey}:${sectionId}`;
126}
127
128export function useReviewCacheForThread(input: {
129 readonly environmentId?: EnvironmentId;
130 readonly threadId?: ThreadId;
131}): ReviewCacheForThread {
132 const threadKey = buildThreadKey(input);
133 const gitSections = useAtomValue(
134 threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM,
135 );
136 const turnDiffById = useAtomValue(
137 threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM,
138 );
139 const selectedSectionId = useAtomValue(
140 threadKey
141 ? reviewSelectedSectionIdByThreadKeyAtom(threadKey)
142 : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM,
143 );
144 const asyncState = useAtomValue(
145 threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM,
146 );
147 const expandedFileIdsBySection = useAtomValue(
148 threadKey
149 ? reviewExpandedFileIdsByThreadKeyAtom(threadKey)
150 : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM,
151 );
152 const revealedLargeFileIdsBySection = useAtomValue(
153 threadKey
154 ? reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey)
155 : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM,
156 );
157 const viewedFileIdsBySection = useAtomValue(
158 threadKey ? reviewViewedFileIdsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM,
159 );
160
161 return {
162 threadKey,
163 gitSections,
164 turnDiffById,
165 selectedSectionId,
166 asyncState,
167 expandedFileIdsBySection,
168 revealedLargeFileIdsBySection,
169 viewedFileIdsBySection,
170 };
171}
172
173export function setReviewGitSections(
174 threadKey: string,
175 sections: ReadonlyArray<ReviewDiffPreviewSource>,
176): void {
177 appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections);
178}
179
180export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void {
181 const atom = reviewTurnDiffByThreadKeyAtom(threadKey);
182 const current = appAtomRegistry.get(atom);
183 appAtomRegistry.set(atom, {
184 ...current,
185 [sectionId]: diff,
186 });
187}
188
189export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void {
190 appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId);
191}
192
193function updateReviewAsyncState(
194 threadKey: string,
195 update: (current: ReviewAsyncState) => ReviewAsyncState,
196): void {
197 const atom = reviewAsyncStateByThreadKeyAtom(threadKey);
198 appAtomRegistry.set(atom, update(appAtomRegistry.get(atom)));
199}
200
201export function setReviewTurnDiffLoading(
202 threadKey: string,
203 sectionId: string,
204 isLoading: boolean,
205): void {
206 updateReviewAsyncState(threadKey, (current) => {
207 const loadingTurnIds = { ...current.loadingTurnIds };
208 if (isLoading) {
209 loadingTurnIds[sectionId] = true;
210 } else {
211 delete loadingTurnIds[sectionId];
212 }
213 return {
214 ...current,
215 loadingTurnIds,
216 };
217 });
218}
219
220export function setReviewAsyncError(threadKey: string, error: string | null): void {
221 updateReviewAsyncState(threadKey, (current) => ({
222 ...current,
223 error,
224 }));
225}
226
227export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState {
228 return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey));
229}
230
231export function updateReviewExpandedFileIds(
232 threadKey: string,
233 sectionId: string,
234 update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined,
235): void {
236 const atom = reviewExpandedFileIdsByThreadKeyAtom(threadKey);
237 const current = appAtomRegistry.get(atom);
238 const nextValue = update(current[sectionId]);
239 appAtomRegistry.set(atom, {
240 ...current,
241 [sectionId]: nextValue,
242 });
243}
244
245export function updateReviewRevealedLargeFileIds(
246 threadKey: string,
247 sectionId: string,
248 update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined,
249): void {
250 const atom = reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey);
251 const current = appAtomRegistry.get(atom);
252 const nextValue = update(current[sectionId]);
253 appAtomRegistry.set(atom, {
254 ...current,
255 [sectionId]: nextValue,
256 });
257}
258
259export function updateReviewViewedFileIds(
260 threadKey: string,
261 sectionId: string,
262 update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined,
263): void {
264 const atom = reviewViewedFileIdsByThreadKeyAtom(threadKey);
265 const current = appAtomRegistry.get(atom);
266 const nextValue = update(current[sectionId]);
267 appAtomRegistry.set(atom, {
268 ...current,
269 [sectionId]: nextValue,
270 });
271}
272
273export function getCachedReviewParsedDiff(input: {
274 readonly threadKey: string | null;
275 readonly sectionId: string | null;
276 readonly diff: string | null | undefined;
277}): ReviewParsedDiff {
278 if (!input.threadKey || !input.sectionId) {
279 return buildReviewParsedDiff(input.diff, input.sectionId ?? "mobile-review");
280 }
281
282 const cacheKey = buildSectionCacheKey(input.threadKey, input.sectionId);
283 const normalizedDiff = input.diff?.trim() ?? null;
284 const atom = reviewParsedDiffBySectionCacheKeyAtom(cacheKey);
285 const cached = appAtomRegistry.get(atom);
286 if (cached && cached.diff === normalizedDiff) {
287 return cached.parsed;
288 }
289
290 const parsed = buildReviewParsedDiff(input.diff, input.sectionId);
291 appAtomRegistry.set(atom, {
292 diff: normalizedDiff,
293 parsed,
294 });
295 return parsed;
296}
apps/mobile/src/features/review/reviewWordDiffs.test.ts created+93
...@@ -0,0 +1,93 @@
1import { describe, expect, it } from "vitest";
2
3import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
4
5describe("computeWordAltDiffRanges", () => {
6 it("joins adjacent word replacements across a single shared separator", () => {
7 const ranges = computeWordAltDiffRanges({
8 deletionLine: "old old",
9 additionLine: "new new",
10 });
11
12 expect(ranges.deletion).toEqual([{ start: 0, end: "old old".length }]);
13 expect(ranges.addition).toEqual([{ start: 0, end: "new new".length }]);
14 });
15
16 it("skips inline word diffs for long lines", () => {
17 const longDeletion = `const before = "${"a".repeat(1_001)}";`;
18 const longAddition = `const after = "${"b".repeat(1_001)}";`;
19
20 const ranges = computeWordAltDiffRanges({
21 deletionLine: longDeletion,
22 additionLine: longAddition,
23 });
24
25 expect(ranges.deletion).toEqual([]);
26 expect(ranges.addition).toEqual([]);
27 });
28});
29
30describe("applyDiffRangesToTokens", () => {
31 it("splits highlighted fragments out of syntax tokens", () => {
32 const tokens = [
33 {
34 content: "const before = 1;",
35 color: "#fff",
36 fontStyle: null,
37 },
38 ];
39 const nextTokens = applyDiffRangesToTokens(tokens, [{ start: 6, end: 12 }]);
40
41 expect(nextTokens).toEqual([
42 {
43 content: "const ",
44 color: "#fff",
45 fontStyle: null,
46 diffHighlight: false,
47 },
48 {
49 content: "before",
50 color: "#fff",
51 fontStyle: null,
52 diffHighlight: true,
53 },
54 {
55 content: " = 1;",
56 color: "#fff",
57 fontStyle: null,
58 diffHighlight: false,
59 },
60 ]);
61 });
62
63 it("skips exhausted ranges when later tokens start after the last range", () => {
64 const tokens = [
65 {
66 content: "a",
67 color: "#fff",
68 fontStyle: null,
69 },
70 {
71 content: "b",
72 color: "#fff",
73 fontStyle: null,
74 },
75 ];
76
77 const nextTokens = applyDiffRangesToTokens(tokens, [{ start: 0, end: 1 }]);
78
79 expect(nextTokens).toEqual([
80 {
81 content: "a",
82 color: "#fff",
83 fontStyle: null,
84 diffHighlight: true,
85 },
86 {
87 content: "b",
88 color: "#fff",
89 fontStyle: null,
90 },
91 ]);
92 });
93});
apps/mobile/src/features/review/reviewWordDiffs.ts created+266
...@@ -0,0 +1,266 @@
1import { diffWordsWithSpace } from "diff";
2
3import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
4
5interface ReviewDiffOperation {
6 readonly value: string;
7 readonly added?: true;
8 readonly removed?: true;
9}
10
11interface ReviewDiffHighlightRange {
12 readonly start: number;
13 readonly end: number;
14}
15
16const REVIEW_MAX_WORD_ALT_LINE_LENGTH = 1_000;
17
18function cleanLineEnding(value: string): string {
19 return value.endsWith("\n") ? value.slice(0, -1) : value;
20}
21
22function pushOrJoinSpan(input: {
23 readonly operation: ReviewDiffOperation;
24 readonly spans: Array<[0 | 1, string]>;
25 readonly enableJoin: boolean;
26 readonly isNeutral?: boolean;
27 readonly isLastOperation?: boolean;
28}): void {
29 const { operation, spans, enableJoin, isNeutral = false, isLastOperation = false } = input;
30 const lastSpan = spans.at(-1);
31
32 if (!lastSpan || isLastOperation || !enableJoin) {
33 spans.push([isNeutral ? 0 : 1, operation.value]);
34 return;
35 }
36
37 const lastSpanIsNeutral = lastSpan[0] === 0;
38 if (
39 isNeutral === lastSpanIsNeutral ||
40 (isNeutral && operation.value.length === 1 && !lastSpanIsNeutral)
41 ) {
42 lastSpan[1] += operation.value;
43 return;
44 }
45
46 spans.push([isNeutral ? 0 : 1, operation.value]);
47}
48
49function spansToRanges(
50 spans: ReadonlyArray<readonly [0 | 1, string]>,
51): ReadonlyArray<ReviewDiffHighlightRange> {
52 const ranges: ReviewDiffHighlightRange[] = [];
53 let offset = 0;
54
55 spans.forEach(([kind, value]) => {
56 const nextOffset = offset + value.length;
57 if (kind === 1 && value.length > 0) {
58 ranges.push({ start: offset, end: nextOffset });
59 }
60 offset = nextOffset;
61 });
62
63 return ranges;
64}
65
66function mergeNearbyRanges(
67 ranges: ReadonlyArray<ReviewDiffHighlightRange>,
68): ReadonlyArray<ReviewDiffHighlightRange> {
69 if (ranges.length <= 1) {
70 return ranges;
71 }
72
73 const merged: ReviewDiffHighlightRange[] = [];
74
75 ranges.forEach((range) => {
76 const previous = merged.at(-1);
77 if (previous && range.start - previous.end <= 1) {
78 merged[merged.length - 1] = { start: previous.start, end: range.end };
79 return;
80 }
81 merged.push({ ...range });
82 });
83
84 return merged;
85}
86
87function appendTokenSegment(
88 target: ReviewHighlightedToken[],
89 source: ReviewHighlightedToken,
90 content: string,
91 diffHighlight: boolean,
92): void {
93 if (content.length === 0) {
94 return;
95 }
96
97 const previous = target.at(-1);
98 if (
99 previous &&
100 previous.color === source.color &&
101 previous.fontStyle === source.fontStyle &&
102 previous.diffHighlight === diffHighlight
103 ) {
104 previous.content += content;
105 return;
106 }
107
108 target.push({
109 content,
110 color: source.color,
111 fontStyle: source.fontStyle,
112 diffHighlight,
113 });
114}
115
116export function computeWordAltDiffRanges(input: {
117 readonly deletionLine: string;
118 readonly additionLine: string;
119}): {
120 readonly deletion: ReadonlyArray<ReviewDiffHighlightRange>;
121 readonly addition: ReadonlyArray<ReviewDiffHighlightRange>;
122} {
123 const deletionLine = cleanLineEnding(input.deletionLine);
124 const additionLine = cleanLineEnding(input.additionLine);
125
126 if (deletionLine.length === 0 && additionLine.length === 0) {
127 return { deletion: [], addition: [] };
128 }
129
130 if (
131 deletionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH ||
132 additionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH
133 ) {
134 return { deletion: [], addition: [] };
135 }
136
137 const operations = diffWordsWithSpace(
138 deletionLine,
139 additionLine,
140 ) as ReadonlyArray<ReviewDiffOperation>;
141 const deletionSpans: Array<[0 | 1, string]> = [];
142 const additionSpans: Array<[0 | 1, string]> = [];
143 const lastOperation = operations.at(-1);
144
145 operations.forEach((operation) => {
146 const isLastOperation = operation === lastOperation;
147 if (!operation.added && !operation.removed) {
148 pushOrJoinSpan({
149 operation,
150 spans: deletionSpans,
151 enableJoin: true,
152 isNeutral: true,
153 isLastOperation,
154 });
155 pushOrJoinSpan({
156 operation,
157 spans: additionSpans,
158 enableJoin: true,
159 isNeutral: true,
160 isLastOperation,
161 });
162 return;
163 }
164
165 if (operation.removed) {
166 pushOrJoinSpan({
167 operation,
168 spans: deletionSpans,
169 enableJoin: true,
170 isLastOperation,
171 });
172 return;
173 }
174
175 pushOrJoinSpan({
176 operation,
177 spans: additionSpans,
178 enableJoin: true,
179 isLastOperation,
180 });
181 });
182
183 return {
184 deletion: mergeNearbyRanges(spansToRanges(deletionSpans)),
185 addition: mergeNearbyRanges(spansToRanges(additionSpans)),
186 };
187}
188
189export function applyDiffRangesToTokens(
190 tokens: ReadonlyArray<ReviewHighlightedToken>,
191 ranges: ReadonlyArray<ReviewDiffHighlightRange>,
192): ReadonlyArray<ReviewHighlightedToken> {
193 if (tokens.length === 0 || ranges.length === 0) {
194 return tokens;
195 }
196
197 const nextTokens: ReviewHighlightedToken[] = [];
198 let tokenOffset = 0;
199 let rangeIndex = 0;
200
201 tokens.forEach((token) => {
202 const tokenStart = tokenOffset;
203 const tokenEnd = tokenStart + token.content.length;
204 tokenOffset = tokenEnd;
205
206 if (token.content.length === 0) {
207 nextTokens.push(token);
208 return;
209 }
210
211 while (rangeIndex < ranges.length && ranges[rangeIndex]!.end <= tokenStart) {
212 rangeIndex += 1;
213 }
214
215 if ((ranges[rangeIndex]?.start ?? Number.POSITIVE_INFINITY) >= tokenEnd) {
216 nextTokens.push(token.diffHighlight ? { ...token, diffHighlight: false } : token);
217 return;
218 }
219
220 let cursor = tokenStart;
221 let localRangeIndex = rangeIndex;
222
223 while ((ranges[localRangeIndex]?.start ?? Number.POSITIVE_INFINITY) < tokenEnd) {
224 const range = ranges[localRangeIndex];
225 if (!range) {
226 break;
227 }
228
229 if (range.start > cursor) {
230 appendTokenSegment(
231 nextTokens,
232 token,
233 token.content.slice(cursor - tokenStart, range.start - tokenStart),
234 false,
235 );
236 }
237
238 const highlightedStart = Math.max(cursor, range.start);
239 const highlightedEnd = Math.min(tokenEnd, range.end);
240
241 if (highlightedEnd > highlightedStart) {
242 appendTokenSegment(
243 nextTokens,
244 token,
245 token.content.slice(highlightedStart - tokenStart, highlightedEnd - tokenStart),
246 true,
247 );
248 }
249
250 cursor = highlightedEnd;
251 if (range.end <= tokenEnd) {
252 localRangeIndex += 1;
253 } else {
254 break;
255 }
256 }
257
258 if (cursor < tokenEnd) {
259 appendTokenSegment(nextTokens, token, token.content.slice(cursor - tokenStart), false);
260 }
261
262 rangeIndex = localRangeIndex;
263 });
264
265 return nextTokens;
266}
apps/mobile/src/features/review/shikiReviewHighlighter.test.ts created+121
...@@ -0,0 +1,121 @@
1import { describe, expect, it } from "vitest";
2
3import type { ReviewRenderableFile } from "./reviewModel";
4import { highlightReviewFile } from "./shikiReviewHighlighter";
5
6function makeRenderableFile(
7 input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">,
8): ReviewRenderableFile {
9 return {
10 id: input.path,
11 cacheKey: input.path,
12 previousPath: null,
13 changeType: "new",
14 additions: 0,
15 deletions: 0,
16 languageHint: null,
17 additionLines: [],
18 deletionLines: [],
19 rows: [],
20 ...input,
21 };
22}
23
24describe("highlightReviewFile", () => {
25 it("preserves one highlighted token row per diff line even without trailing newlines", async () => {
26 const file = makeRenderableFile({
27 path: "apps/mobile/src/example.txt",
28 additionLines: [
29 'const items = ["a"];',
30 'expect(items).toEqual(["a"]);',
31 "const next = items.map((item) => item.toUpperCase());",
32 'expect(next).toContain("A");',
33 ],
34 });
35
36 const highlighted = await highlightReviewFile(file, "light");
37
38 expect(highlighted.additionLines).toHaveLength(file.additionLines.length);
39 expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe(
40 file.additionLines[0],
41 );
42 expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe(
43 file.additionLines[1],
44 );
45 expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe(
46 file.additionLines[2],
47 );
48 expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe(
49 file.additionLines[3],
50 );
51 });
52
53 it("adds word-alt diff emphasis for paired deletion and addition lines", async () => {
54 const file = makeRenderableFile({
55 path: "apps/mobile/src/example-inline-diff.txt",
56 additionLines: ["const after = 2;"],
57 deletionLines: ["const before = 1;"],
58 rows: [
59 {
60 kind: "line",
61 id: "delete-1",
62 change: "delete",
63 oldLineNumber: 1,
64 newLineNumber: null,
65 content: "const before = 1;",
66 additionTokenIndex: null,
67 deletionTokenIndex: 0,
68 comparison: { change: "add", tokenIndex: 0 },
69 },
70 {
71 kind: "line",
72 id: "add-1",
73 change: "add",
74 oldLineNumber: null,
75 newLineNumber: 1,
76 content: "const after = 2;",
77 additionTokenIndex: 0,
78 deletionTokenIndex: null,
79 comparison: { change: "delete", tokenIndex: 0 },
80 },
81 ],
82 });
83
84 const highlighted = await highlightReviewFile(file, "light");
85
86 expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true);
87 expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true);
88 });
89
90 it("falls back to plain tokens for very long lines", async () => {
91 const longLine = `const value = "${"a".repeat(1_100)}";`;
92 const file = makeRenderableFile({
93 path: "apps/mobile/src/example-long-line.txt",
94 additionLines: [longLine],
95 rows: [
96 {
97 kind: "line",
98 id: "add-1",
99 change: "add",
100 oldLineNumber: null,
101 newLineNumber: 1,
102 content: longLine,
103 additionTokenIndex: 0,
104 deletionTokenIndex: null,
105 comparison: null,
106 },
107 ],
108 });
109
110 const highlighted = await highlightReviewFile(file, "light");
111
112 expect(highlighted.additionLines).toHaveLength(1);
113 expect(highlighted.additionLines[0]).toEqual([
114 {
115 content: longLine,
116 color: null,
117 fontStyle: null,
118 },
119 ]);
120 });
121});
apps/mobile/src/features/review/shikiReviewHighlighter.ts created+1037
...@@ -0,0 +1,1037 @@
1import { createHighlighterCore, type HighlighterCore } from "@shikijs/core";
2import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript";
3import bashLanguage from "@shikijs/langs/bash";
4import javascriptLanguage from "@shikijs/langs/javascript";
5import jsonLanguage from "@shikijs/langs/json";
6import jsxLanguage from "@shikijs/langs/jsx";
7import tsxLanguage from "@shikijs/langs/tsx";
8import typescriptLanguage from "@shikijs/langs/typescript";
9import yamlLanguage from "@shikijs/langs/yaml";
10import githubDarkDefault from "@shikijs/themes/github-dark-default";
11import githubLightDefault from "@shikijs/themes/github-light-default";
12import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName";
13
14import {
15 resolveReviewHighlighterEngine,
16 resolveReviewHighlighterEnginePreference,
17 type ReviewHighlighterEngine,
18} from "./reviewHighlighterEngine";
19import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel";
20import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
21
22export type ReviewDiffTheme = "light" | "dark";
23export type { ReviewHighlighterEngine };
24
25export interface ReviewHighlightedToken {
26 content: string;
27 readonly color: string | null;
28 readonly fontStyle: number | null;
29 readonly diffHighlight?: boolean;
30}
31
32export interface ReviewHighlightedFile {
33 readonly additionLines: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>;
34 readonly deletionLines: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>;
35}
36
37export interface ReviewHighlightFileProgress {
38 readonly highlightedFile: ReviewHighlightedFile;
39 readonly complete: boolean;
40 readonly highlightedLineCount: number;
41}
42
43const SHIKI_THEME_NAME_BY_SCHEME = {
44 light: "github-light-default",
45 dark: "github-dark-default",
46} as const;
47const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE =
48 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ??
49 (process.env.NODE_ENV === "test" ? "javascript" : "native");
50const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference(
51 REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
52);
53const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag(
54 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE,
55 false,
56);
57const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8;
58const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8;
59const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200;
60const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000;
61const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>();
62const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>();
63const REVIEW_INITIAL_LANGUAGE_MODULES = [
64 bashLanguage,
65 javascriptLanguage,
66 jsonLanguage,
67 jsxLanguage,
68 tsxLanguage,
69 typescriptLanguage,
70 yamlLanguage,
71] satisfies Parameters<typeof createHighlighterCore>[0]["langs"];
72const loadedLanguages = new Set<string>([
73 "text",
74 "bash",
75 "javascript",
76 "json",
77 "jsx",
78 "tsx",
79 "typescript",
80 "yaml",
81]);
82const languageLoadingPromises = new Map<string, Promise<boolean>>();
83const languageImports: Partial<Record<string, () => Promise<unknown>>> = {
84 javascript: () => import("@shikijs/langs/javascript"),
85 typescript: () => import("@shikijs/langs/typescript"),
86 jsx: () => import("@shikijs/langs/jsx"),
87 tsx: () => import("@shikijs/langs/tsx"),
88 python: () => import("@shikijs/langs/python"),
89 rust: () => import("@shikijs/langs/rust"),
90 go: () => import("@shikijs/langs/go"),
91 java: () => import("@shikijs/langs/java"),
92 kotlin: () => import("@shikijs/langs/kotlin"),
93 swift: () => import("@shikijs/langs/swift"),
94 "objective-c": () => import("@shikijs/langs/objective-c"),
95 c: () => import("@shikijs/langs/c"),
96 cpp: () => import("@shikijs/langs/cpp"),
97 csharp: () => import("@shikijs/langs/csharp"),
98 php: () => import("@shikijs/langs/php"),
99 ruby: () => import("@shikijs/langs/ruby"),
100 lua: () => import("@shikijs/langs/lua"),
101 perl: () => import("@shikijs/langs/perl"),
102 r: () => import("@shikijs/langs/r"),
103 dart: () => import("@shikijs/langs/dart"),
104 scala: () => import("@shikijs/langs/scala"),
105 elixir: () => import("@shikijs/langs/elixir"),
106 haskell: () => import("@shikijs/langs/haskell"),
107 clojure: () => import("@shikijs/langs/clojure"),
108 ocaml: () => import("@shikijs/langs/ocaml"),
109 fsharp: () => import("@shikijs/langs/fsharp"),
110 erlang: () => import("@shikijs/langs/erlang"),
111 zig: () => import("@shikijs/langs/zig"),
112 nim: () => import("@shikijs/langs/nim"),
113 html: () => import("@shikijs/langs/html"),
114 css: () => import("@shikijs/langs/css"),
115 scss: () => import("@shikijs/langs/scss"),
116 less: () => import("@shikijs/langs/less"),
117 xml: () => import("@shikijs/langs/xml"),
118 svg: () => import("@shikijs/langs/xml"),
119 vue: () => import("@shikijs/langs/vue"),
120 svelte: () => import("@shikijs/langs/svelte"),
121 astro: () => import("@shikijs/langs/astro"),
122 json: () => import("@shikijs/langs/json"),
123 jsonc: () => import("@shikijs/langs/jsonc"),
124 yaml: () => import("@shikijs/langs/yaml"),
125 toml: () => import("@shikijs/langs/toml"),
126 ini: () => import("@shikijs/langs/ini"),
127 bash: () => import("@shikijs/langs/bash"),
128 shellscript: () => import("@shikijs/langs/shellscript"),
129 powershell: () => import("@shikijs/langs/powershell"),
130 fish: () => import("@shikijs/langs/fish"),
131 sql: () => import("@shikijs/langs/sql"),
132 graphql: () => import("@shikijs/langs/graphql"),
133 prisma: () => import("@shikijs/langs/prisma"),
134 docker: () => import("@shikijs/langs/docker"),
135 hcl: () => import("@shikijs/langs/hcl"),
136 nix: () => import("@shikijs/langs/nix"),
137 markdown: () => import("@shikijs/langs/markdown"),
138 mdx: () => import("@shikijs/langs/mdx"),
139 tex: () => import("@shikijs/langs/tex"),
140 diff: () => import("@shikijs/langs/diff"),
141 regex: () => import("@shikijs/langs/regex"),
142 viml: () => import("@shikijs/langs/viml"),
143 makefile: () => import("@shikijs/langs/makefile"),
144 cmake: () => import("@shikijs/langs/cmake"),
145 groovy: () => import("@shikijs/langs/groovy"),
146};
147
148const languageAliases: Record<string, string> = {
149 js: "javascript",
150 mjs: "javascript",
151 cjs: "javascript",
152 ts: "typescript",
153 mts: "typescript",
154 cts: "typescript",
155 py: "python",
156 rb: "ruby",
157 rs: "rust",
158 sh: "bash",
159 zsh: "bash",
160 shell: "shellscript",
161 yml: "yaml",
162 md: "markdown",
163 "c++": "cpp",
164 "c#": "csharp",
165 cs: "csharp",
166 dockerfile: "docker",
167 vim: "viml",
168 objc: "objective-c",
169 objectivec: "objective-c",
170 "obj-c": "objective-c",
171 ps1: "powershell",
172 pwsh: "powershell",
173 hs: "haskell",
174 ex: "elixir",
175 exs: "elixir",
176 erl: "erlang",
177 clj: "clojure",
178 ml: "ocaml",
179 fs: "fsharp",
180 tf: "hcl",
181 make: "makefile",
182 plain: "text",
183 plaintext: "text",
184 txt: "text",
185};
186let highlighterPromise: Promise<HighlighterCore> | null = null;
187let activeHighlighterEnginePromise: Promise<ReviewHighlighterEngine> | null = null;
188
189type LoadedLanguageModule = {
190 default: Parameters<HighlighterCore["loadLanguage"]>[0];
191};
192
193function resolveReviewHighlighterBooleanFlag(
194 value: string | undefined,
195 defaultValue: boolean,
196): boolean {
197 switch (value) {
198 case "1":
199 case "true":
200 return true;
201 case "0":
202 case "false":
203 return false;
204 default:
205 return defaultValue;
206 }
207}
208
209function isReviewHighlighterDebugLoggingEnabled(): boolean {
210 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
211}
212
213function logReviewHighlighterDiagnostic(message: string, details?: Record<string, unknown>): void {
214 if (!isReviewHighlighterDebugLoggingEnabled()) {
215 return;
216 }
217
218 if (details) {
219 console.log(`[review-highlighter] ${message}`, details);
220 return;
221 }
222
223 console.log(`[review-highlighter] ${message}`);
224}
225
226function logReviewHighlighterDiagnosticError(message: string, error: unknown): void {
227 if (!isReviewHighlighterDebugLoggingEnabled()) {
228 return;
229 }
230
231 if (error instanceof Error) {
232 console.error(`[review-highlighter] ${message}`, {
233 name: error.name,
234 message: error.message,
235 stack: error.stack,
236 });
237 return;
238 }
239
240 console.error(`[review-highlighter] ${message}`, error);
241}
242
243function stripTrailingNewline(value: string): string {
244 return value.endsWith("\n") ? value.slice(0, -1) : value;
245}
246
247function joinPatchLines(lines: ReadonlyArray<string>): string {
248 return lines.map(stripTrailingNewline).join("\n");
249}
250
251function waitForNextFrame(): Promise<void> {
252 return new Promise((resolve) => {
253 setTimeout(resolve, 0);
254 });
255}
256
257async function getHighlighter(): Promise<HighlighterCore> {
258 if (!highlighterPromise) {
259 const configuredHighlighterPromise = (async () => {
260 let nativeEngineAvailable = false;
261
262 logReviewHighlighterDiagnostic("initializing", {
263 configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
264 preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
265 resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE,
266 });
267
268 const themes = [githubLightDefault, githubDarkDefault];
269
270 if (REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE !== "javascript") {
271 try {
272 const nativeEngineModule = await import("react-native-shiki-engine");
273 nativeEngineAvailable = nativeEngineModule.isNativeEngineAvailable();
274 logReviewHighlighterDiagnostic("checked native engine availability", {
275 nativeEngineAvailable,
276 });
277
278 if (nativeEngineAvailable) {
279 logReviewHighlighterDiagnostic("creating native regex engine");
280 const highlighter = await createHighlighterCore({
281 themes,
282 langs: REVIEW_INITIAL_LANGUAGE_MODULES,
283 engine: nativeEngineModule.createNativeEngine(),
284 });
285 logReviewHighlighterDiagnostic("using native engine");
286 return {
287 highlighter,
288 engine: "native" as const,
289 };
290 }
291 } catch (error) {
292 logReviewHighlighterDiagnosticError(
293 "native engine initialization failed; falling back to javascript",
294 error,
295 );
296 nativeEngineAvailable = false;
297 }
298 } else {
299 logReviewHighlighterDiagnostic("skipping native engine probe", {
300 reason: "preference-forced-javascript",
301 });
302 }
303
304 const engine = resolveReviewHighlighterEngine(
305 REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
306 nativeEngineAvailable,
307 );
308 const highlighter = await createHighlighterCore({
309 themes,
310 langs: REVIEW_INITIAL_LANGUAGE_MODULES,
311 engine: createJavaScriptRegexEngine(),
312 });
313 logReviewHighlighterDiagnostic("using javascript engine", {
314 resolvedEngine: engine,
315 });
316 return {
317 highlighter,
318 engine,
319 };
320 })();
321
322 highlighterPromise = configuredHighlighterPromise
323 .then((result) => result.highlighter)
324 .catch((error) => {
325 highlighterPromise = null;
326 activeHighlighterEnginePromise = null;
327 throw error;
328 });
329 activeHighlighterEnginePromise = configuredHighlighterPromise
330 .then((result) => result.engine)
331 .catch((error) => {
332 activeHighlighterEnginePromise = null;
333 throw error;
334 });
335 }
336
337 return highlighterPromise;
338}
339
340export async function getActiveReviewHighlighterEngine(): Promise<ReviewHighlighterEngine> {
341 await getHighlighter();
342 return activeHighlighterEnginePromise ?? Promise.resolve("javascript");
343}
344
345export async function prepareReviewHighlighter(): Promise<void> {
346 await getHighlighter();
347}
348
349export async function prepareReviewHighlighterLanguages(
350 languages: ReadonlyArray<string>,
351): Promise<void> {
352 const highlighter = await getHighlighter();
353 await Promise.all(
354 languages.map(async (language) => {
355 const candidate = resolveLanguageAlias(language);
356 if (candidate === "text" || !(candidate in languageImports)) {
357 return;
358 }
359
360 await loadSingleLanguage(highlighter, candidate);
361 }),
362 );
363}
364
365function resolveLanguageAlias(language: string): string {
366 const normalized = language.toLowerCase();
367 return languageAliases[normalized] ?? normalized;
368}
369
370function resolveLoadedLanguageFromPath(
371 path: string,
372 languageHint: string | null = null,
373): string | null {
374 const detectedLanguage = languageHint ?? getFiletypeFromFileName(path);
375 if (!detectedLanguage) {
376 return "text";
377 }
378
379 const candidate = resolveLanguageAlias(detectedLanguage);
380 if (candidate === "text" || candidate === "ansi") {
381 return "text";
382 }
383
384 if (!(candidate in languageImports)) {
385 return "text";
386 }
387
388 return loadedLanguages.has(candidate) ? candidate : null;
389}
390
391async function loadSingleLanguage(
392 highlighter: HighlighterCore,
393 language: string,
394): Promise<boolean> {
395 if (loadedLanguages.has(language)) {
396 return true;
397 }
398
399 const existingPromise = languageLoadingPromises.get(language);
400 if (existingPromise) {
401 return existingPromise;
402 }
403
404 const importer = languageImports[language];
405 if (!importer) {
406 return false;
407 }
408
409 const loadingPromise = (async () => {
410 try {
411 const languageModule = (await importer()) as LoadedLanguageModule;
412 await highlighter.loadLanguage(languageModule.default);
413 loadedLanguages.add(language);
414 return true;
415 } catch {
416 return false;
417 } finally {
418 languageLoadingPromises.delete(language);
419 }
420 })();
421
422 languageLoadingPromises.set(language, loadingPromise);
423 return loadingPromise;
424}
425
426async function resolveLanguageFromPath(
427 path: string,
428 languageHint: string | null = null,
429): Promise<string> {
430 const loadedLanguage = resolveLoadedLanguageFromPath(path, languageHint);
431 if (loadedLanguage) {
432 return loadedLanguage;
433 }
434
435 const detectedLanguage = languageHint ?? getFiletypeFromFileName(path);
436 if (!detectedLanguage) {
437 return "text";
438 }
439
440 const candidate = resolveLanguageAlias(detectedLanguage);
441 if (candidate === "text" || candidate === "ansi") {
442 return "text";
443 }
444
445 if (!(candidate in languageImports)) {
446 return "text";
447 }
448
449 if (loadedLanguages.has(candidate)) {
450 return candidate;
451 }
452
453 const highlighter = await getHighlighter();
454 const loaded = await loadSingleLanguage(highlighter, candidate);
455 if (!loaded) {
456 return "text";
457 }
458
459 return candidate;
460}
461
462async function resolveLanguage(file: ReviewRenderableFile): Promise<string> {
463 return (
464 resolveLoadedLanguageFromPath(file.path, file.languageHint) ??
465 (await resolveLanguageFromPath(file.path, file.languageHint))
466 );
467}
468
469function normalizeHighlightedLines(
470 tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>,
471): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> {
472 return tokenLines.map((line) =>
473 line.map((token) => ({
474 content: token.content,
475 color: token.color ?? null,
476 fontStyle: token.fontStyle ?? null,
477 })),
478 );
479}
480
481function makePlainHighlightedLines(
482 lines: ReadonlyArray<string>,
483): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> {
484 return lines.map((line) => [
485 {
486 content: stripTrailingNewline(line),
487 color: null,
488 fontStyle: null,
489 },
490 ]);
491}
492
493function applyWordAltDiffHighlightsToFile(
494 file: ReviewRenderableFile,
495 highlighted: ReviewHighlightedFile,
496): ReviewHighlightedFile {
497 const nextAdditionLines = [...highlighted.additionLines];
498 const nextDeletionLines = [...highlighted.deletionLines];
499 const processedPairs = new Set<string>();
500 let changed = false;
501
502 file.rows.forEach((row) => {
503 if (row.kind !== "line" || row.change === "context" || !row.comparison) {
504 return;
505 }
506
507 const deletionTokenIndex =
508 row.change === "delete"
509 ? row.deletionTokenIndex
510 : row.comparison.change === "delete"
511 ? row.comparison.tokenIndex
512 : null;
513 const additionTokenIndex =
514 row.change === "add"
515 ? row.additionTokenIndex
516 : row.comparison.change === "add"
517 ? row.comparison.tokenIndex
518 : null;
519
520 if (deletionTokenIndex === null || additionTokenIndex === null) {
521 return;
522 }
523
524 const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`;
525 if (processedPairs.has(pairKey)) {
526 return;
527 }
528 processedPairs.add(pairKey);
529
530 const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? "");
531 const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? "");
532 const ranges = computeWordAltDiffRanges({ deletionLine, additionLine });
533
534 if (ranges.deletion.length > 0) {
535 nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens(
536 nextDeletionLines[deletionTokenIndex] ?? [],
537 ranges.deletion,
538 );
539 changed = true;
540 }
541
542 if (ranges.addition.length > 0) {
543 nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens(
544 nextAdditionLines[additionTokenIndex] ?? [],
545 ranges.addition,
546 );
547 changed = true;
548 }
549 });
550
551 return changed
552 ? {
553 additionLines: nextAdditionLines,
554 deletionLines: nextDeletionLines,
555 }
556 : highlighted;
557}
558
559function applyWordAltDiffHighlightsToSelectedLines(input: {
560 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
561 readonly tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>>;
562}): Record<string, ReadonlyArray<ReviewHighlightedToken>> {
563 const additionLineByTokenIndex = new Map<number, ReviewRenderableLineRow>();
564 const deletionLineByTokenIndex = new Map<number, ReviewRenderableLineRow>();
565
566 input.lines.forEach((line) => {
567 if (line.change === "add" && line.additionTokenIndex !== null) {
568 additionLineByTokenIndex.set(line.additionTokenIndex, line);
569 }
570 if (line.change === "delete" && line.deletionTokenIndex !== null) {
571 deletionLineByTokenIndex.set(line.deletionTokenIndex, line);
572 }
573 });
574
575 const nextTokenMap = { ...input.tokenMap };
576 const processedPairs = new Set<string>();
577
578 input.lines.forEach((line) => {
579 if (line.change === "context" || !line.comparison) {
580 return;
581 }
582
583 const pairedDeletionLine =
584 line.change === "delete"
585 ? line
586 : line.comparison.change === "delete"
587 ? deletionLineByTokenIndex.get(line.comparison.tokenIndex)
588 : undefined;
589 const pairedAdditionLine =
590 line.change === "add"
591 ? line
592 : line.comparison.change === "add"
593 ? additionLineByTokenIndex.get(line.comparison.tokenIndex)
594 : undefined;
595
596 if (
597 !pairedDeletionLine ||
598 !pairedAdditionLine ||
599 pairedDeletionLine.deletionTokenIndex === null ||
600 pairedAdditionLine.additionTokenIndex === null
601 ) {
602 return;
603 }
604
605 const pairKey = `${pairedDeletionLine.deletionTokenIndex}:${pairedAdditionLine.additionTokenIndex}`;
606 if (processedPairs.has(pairKey)) {
607 return;
608 }
609 processedPairs.add(pairKey);
610
611 const ranges = computeWordAltDiffRanges({
612 deletionLine: pairedDeletionLine.content,
613 additionLine: pairedAdditionLine.content,
614 });
615
616 if (ranges.deletion.length > 0) {
617 nextTokenMap[pairedDeletionLine.id] = applyDiffRangesToTokens(
618 nextTokenMap[pairedDeletionLine.id] ?? [],
619 ranges.deletion,
620 );
621 }
622 if (ranges.addition.length > 0) {
623 nextTokenMap[pairedAdditionLine.id] = applyDiffRangesToTokens(
624 nextTokenMap[pairedAdditionLine.id] ?? [],
625 ranges.addition,
626 );
627 }
628 });
629
630 return nextTokenMap;
631}
632
633async function highlightLines(
634 code: string,
635 language: string,
636 theme: string,
637): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> {
638 if (code.length === 0) {
639 return [];
640 }
641
642 const highlighter = await getHighlighter();
643 const sourceLines = code.split("\n");
644 const highlightedLines: Array<ReadonlyArray<ReviewHighlightedToken>> = [];
645 const shortLineBatch: string[] = [];
646
647 const flushShortLineBatch = async (): Promise<void> => {
648 if (shortLineBatch.length === 0) {
649 return;
650 }
651
652 const tokenLines = highlighter.codeToTokensBase(shortLineBatch.join("\n"), {
653 lang: language,
654 theme,
655 });
656 highlightedLines.push(...normalizeHighlightedLines(tokenLines));
657 shortLineBatch.length = 0;
658 };
659
660 for (let lineIndex = 0; lineIndex < sourceLines.length; lineIndex += 1) {
661 const line = sourceLines[lineIndex] ?? "";
662
663 if (line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) {
664 await flushShortLineBatch();
665 highlightedLines.push([{ content: line, color: null, fontStyle: null }]);
666 } else {
667 shortLineBatch.push(line);
668 }
669
670 if (shortLineBatch.length >= REVIEW_HIGHLIGHT_CHUNK_SIZE) {
671 await flushShortLineBatch();
672 }
673
674 if (
675 sourceLines.length > REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD &&
676 lineIndex + 1 < sourceLines.length &&
677 (shortLineBatch.length === 0 || line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH)
678 ) {
679 await waitForNextFrame();
680 }
681 }
682
683 await flushShortLineBatch();
684
685 return highlightedLines;
686}
687
688async function highlightPatchLinesInChunks(input: {
689 readonly lines: ReadonlyArray<string>;
690 readonly language: string;
691 readonly theme: string;
692 readonly onChunk: (
693 startIndex: number,
694 tokens: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>,
695 ) => void;
696}): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> {
697 if (input.lines.length === 0) {
698 return [];
699 }
700
701 const highlighter = await getHighlighter();
702 const highlightedLines: Array<ReadonlyArray<ReviewHighlightedToken>> = [];
703
704 for (
705 let startIndex = 0;
706 startIndex < input.lines.length;
707 startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE
708 ) {
709 const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE);
710 const chunkTokens: Array<ReadonlyArray<ReviewHighlightedToken>> = [];
711 const tokenizableLines: string[] = [];
712 const tokenizableIndexes: number[] = [];
713
714 lineChunk.forEach((line, index) => {
715 const strippedLine = stripTrailingNewline(line);
716 if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) {
717 chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }];
718 return;
719 }
720
721 tokenizableIndexes.push(index);
722 tokenizableLines.push(strippedLine);
723 });
724
725 if (tokenizableLines.length > 0) {
726 const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), {
727 lang: input.language,
728 theme: input.theme,
729 });
730 const normalizedTokenLines = normalizeHighlightedLines(tokenLines);
731
732 tokenizableIndexes.forEach((chunkIndex, tokenIndex) => {
733 chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? [];
734 });
735 }
736
737 const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []);
738 highlightedLines.push(...completedChunk);
739 input.onChunk(startIndex, completedChunk);
740
741 if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) {
742 await waitForNextFrame();
743 }
744 }
745
746 return highlightedLines;
747}
748
749function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string {
750 return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`;
751}
752
753function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void {
754 if (resolvedHighlightCache.has(cacheKey)) {
755 resolvedHighlightCache.delete(cacheKey);
756 }
757
758 resolvedHighlightCache.set(cacheKey, highlighted);
759
760 while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) {
761 const oldestKey = resolvedHighlightCache.keys().next().value;
762 if (oldestKey === undefined) {
763 break;
764 }
765 resolvedHighlightCache.delete(oldestKey);
766 }
767}
768
769export function clearReviewHighlightFileCache(): void {
770 highlightCache.clear();
771 resolvedHighlightCache.clear();
772}
773
774export function getCachedHighlightedReviewFile(
775 file: ReviewRenderableFile,
776 theme: ReviewDiffTheme,
777): ReviewHighlightedFile | null {
778 if (REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
779 return null;
780 }
781
782 return resolvedHighlightCache.get(getHighlightCacheKey(file, theme)) ?? null;
783}
784
785export async function highlightReviewFile(
786 file: ReviewRenderableFile,
787 theme: ReviewDiffTheme,
788): Promise<ReviewHighlightedFile> {
789 const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme];
790 const cacheKey = getHighlightCacheKey(file, theme);
791 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
792 const resolved = resolvedHighlightCache.get(cacheKey);
793 if (resolved) {
794 logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", {
795 fileId: file.id,
796 filePath: file.path,
797 theme,
798 });
799 return resolved;
800 }
801 const cached = highlightCache.get(cacheKey);
802 if (cached) {
803 logReviewHighlighterDiagnostic("file highlight cache hit (pending)", {
804 fileId: file.id,
805 filePath: file.path,
806 theme,
807 });
808 return cached;
809 }
810 }
811
812 const promise = (async () => {
813 const startedAt = Date.now();
814 logReviewHighlighterDiagnostic("file highlight start", {
815 fileId: file.id,
816 filePath: file.path,
817 theme,
818 additionLineCount: file.additionLines.length,
819 deletionLineCount: file.deletionLines.length,
820 rowCount: file.rows.length,
821 });
822 const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint);
823 const language = loadedLanguage ?? (await resolveLanguage(file));
824 if (language === "text") {
825 const highlighted = applyWordAltDiffHighlightsToFile(file, {
826 additionLines: makePlainHighlightedLines(file.additionLines),
827 deletionLines: makePlainHighlightedLines(file.deletionLines),
828 });
829 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
830 storeResolvedHighlightedFile(cacheKey, highlighted);
831 }
832 logReviewHighlighterDiagnostic("file highlight complete", {
833 fileId: file.id,
834 filePath: file.path,
835 theme,
836 language,
837 highlightedAdditionLineCount: highlighted.additionLines.length,
838 highlightedDeletionLineCount: highlighted.deletionLines.length,
839 durationMs: Date.now() - startedAt,
840 });
841 return highlighted;
842 }
843
844 const additionLines = await highlightLines(
845 joinPatchLines(file.additionLines),
846 language,
847 shikiTheme,
848 );
849 await waitForNextFrame();
850 const deletionLines = await highlightLines(
851 joinPatchLines(file.deletionLines),
852 language,
853 shikiTheme,
854 );
855 await waitForNextFrame();
856
857 const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines });
858 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
859 storeResolvedHighlightedFile(cacheKey, highlighted);
860 }
861 logReviewHighlighterDiagnostic("file highlight complete", {
862 fileId: file.id,
863 filePath: file.path,
864 theme,
865 language,
866 highlightedAdditionLineCount: highlighted.additionLines.length,
867 highlightedDeletionLineCount: highlighted.deletionLines.length,
868 durationMs: Date.now() - startedAt,
869 });
870 return highlighted;
871 })();
872
873 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
874 highlightCache.set(cacheKey, promise);
875 }
876 return promise.finally(() => {
877 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
878 highlightCache.delete(cacheKey);
879 }
880 });
881}
882
883export async function streamHighlightReviewFile(
884 file: ReviewRenderableFile,
885 theme: ReviewDiffTheme,
886 onProgress: (progress: ReviewHighlightFileProgress) => void,
887): Promise<ReviewHighlightedFile> {
888 const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme];
889 const cacheKey = getHighlightCacheKey(file, theme);
890 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
891 const resolved = resolvedHighlightCache.get(cacheKey);
892 if (resolved) {
893 onProgress({
894 highlightedFile: resolved,
895 complete: true,
896 highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length,
897 });
898 return resolved;
899 }
900 }
901
902 const startedAt = Date.now();
903 logReviewHighlighterDiagnostic("file stream highlight start", {
904 fileId: file.id,
905 filePath: file.path,
906 theme,
907 additionLineCount: file.additionLines.length,
908 deletionLineCount: file.deletionLines.length,
909 rowCount: file.rows.length,
910 });
911
912 const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint);
913 const language = loadedLanguage ?? (await resolveLanguage(file));
914 if (language === "text") {
915 const highlighted = applyWordAltDiffHighlightsToFile(file, {
916 additionLines: makePlainHighlightedLines(file.additionLines),
917 deletionLines: makePlainHighlightedLines(file.deletionLines),
918 });
919 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
920 storeResolvedHighlightedFile(cacheKey, highlighted);
921 }
922 onProgress({
923 highlightedFile: highlighted,
924 complete: true,
925 highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length,
926 });
927 logReviewHighlighterDiagnostic("file stream highlight complete", {
928 fileId: file.id,
929 filePath: file.path,
930 theme,
931 language,
932 highlightedAdditionLineCount: highlighted.additionLines.length,
933 highlightedDeletionLineCount: highlighted.deletionLines.length,
934 highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length,
935 durationMs: Date.now() - startedAt,
936 });
937 return highlighted;
938 }
939
940 const additionLines: Array<ReadonlyArray<ReviewHighlightedToken>> = [];
941 const deletionLines: Array<ReadonlyArray<ReviewHighlightedToken>> = [];
942 let highlightedLineCount = 0;
943
944 await highlightPatchLinesInChunks({
945 lines: file.additionLines,
946 language,
947 theme: shikiTheme,
948 onChunk: (startIndex, tokens) => {
949 tokens.forEach((lineTokens, index) => {
950 additionLines[startIndex + index] = lineTokens;
951 });
952 highlightedLineCount += tokens.length;
953 },
954 });
955 await waitForNextFrame();
956 await highlightPatchLinesInChunks({
957 lines: file.deletionLines,
958 language,
959 theme: shikiTheme,
960 onChunk: (startIndex, tokens) => {
961 tokens.forEach((lineTokens, index) => {
962 deletionLines[startIndex + index] = lineTokens;
963 });
964 highlightedLineCount += tokens.length;
965 },
966 });
967
968 const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines });
969 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
970 storeResolvedHighlightedFile(cacheKey, highlighted);
971 }
972 onProgress({
973 highlightedFile: highlighted,
974 complete: true,
975 highlightedLineCount,
976 });
977 logReviewHighlighterDiagnostic("file stream highlight complete", {
978 fileId: file.id,
979 filePath: file.path,
980 theme,
981 language,
982 highlightedAdditionLineCount: highlighted.additionLines.length,
983 highlightedDeletionLineCount: highlighted.deletionLines.length,
984 highlightedLineCount,
985 durationMs: Date.now() - startedAt,
986 });
987 return highlighted;
988}
989
990export async function highlightReviewSelectedLines(input: {
991 readonly filePath: string;
992 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
993 readonly theme: ReviewDiffTheme;
994 readonly languageHint?: string | null;
995}): Promise<Record<string, ReadonlyArray<ReviewHighlightedToken>>> {
996 if (input.lines.length === 0) {
997 return {};
998 }
999
1000 const loadedLanguage = resolveLoadedLanguageFromPath(input.filePath, input.languageHint ?? null);
1001 const language =
1002 loadedLanguage ?? (await resolveLanguageFromPath(input.filePath, input.languageHint ?? null));
1003 const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[input.theme];
1004 const additionLikeLines: string[] = [];
1005 const deletionLines: string[] = [];
1006 for (const line of input.lines) {
1007 if (line.change === "delete") {
1008 deletionLines.push(`${line.content}\n`);
1009 } else {
1010 additionLikeLines.push(`${line.content}\n`);
1011 }
1012 }
1013 const [additionTokens, deletionTokens] = await Promise.all([
1014 highlightLines(joinPatchLines(additionLikeLines), language, shikiTheme),
1015 highlightLines(joinPatchLines(deletionLines), language, shikiTheme),
1016 ]);
1017
1018 const tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>> = {};
1019 let additionIndex = 0;
1020 let deletionIndex = 0;
1021
1022 input.lines.forEach((line) => {
1023 if (line.change === "delete") {
1024 tokenMap[line.id] = deletionTokens[deletionIndex] ?? [];
1025 deletionIndex += 1;
1026 return;
1027 }
1028
1029 tokenMap[line.id] = additionTokens[additionIndex] ?? [];
1030 additionIndex += 1;
1031 });
1032
1033 return applyWordAltDiffHighlightsToSelectedLines({
1034 lines: input.lines,
1035 tokenMap,
1036 });
1037}
apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts created+27
...@@ -0,0 +1,27 @@
1import { describe, expect, it } from "vitest";
2
3import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge";
4
5describe("native review diff bridge", () => {
6 it("builds stable reset keys from the rendered diff identity", () => {
7 const input = {
8 threadKey: "env:thread",
9 sectionId: "turn:2",
10 scheme: "dark" as const,
11 diff: "diff --git a/a.ts b/a.ts",
12 fileCount: 1,
13 rowCount: 4,
14 };
15
16 expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input));
17 expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe(
18 buildNativeReviewTokensResetKey(input),
19 );
20 expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:");
21 });
22
23 it("includes diff length in the hash key to reduce accidental collisions", () => {
24 expect(hashReviewDiffKey("abc")).toMatch(/^3:/);
25 expect(hashReviewDiffKey("abcd")).toMatch(/^4:/);
26 });
27});
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts created+152
...@@ -0,0 +1,152 @@
1import { useCallback, useMemo, useState } from "react";
2import type { NativeSyntheticEvent } from "react-native";
3
4import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
5import {
6 createNativeReviewDiffTheme,
7 NATIVE_REVIEW_DIFF_STYLE,
8 type NativeReviewDiffData,
9} from "./nativeReviewDiffAdapter";
10import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";
11
12export function hashReviewDiffKey(diff: string | null | undefined): string {
13 if (!diff) {
14 return "empty";
15 }
16
17 let hash = 5381;
18 for (let index = 0; index < diff.length; index += 1) {
19 hash = (hash * 33) ^ diff.charCodeAt(index);
20 }
21
22 return `${diff.length}:${(hash >>> 0).toString(36)}`;
23}
24
25export function buildNativeReviewTokensResetKey(input: {
26 readonly threadKey: string | null;
27 readonly sectionId: string | null;
28 readonly scheme: NativeReviewDiffHighlightScheme;
29 readonly diff: string | null | undefined;
30 readonly fileCount: number;
31 readonly rowCount: number;
32}): string {
33 return [
34 input.threadKey ?? "none",
35 input.sectionId ?? "none",
36 input.scheme,
37 hashReviewDiffKey(input.diff),
38 input.fileCount,
39 input.rowCount,
40 ].join(":");
41}
42
43export function useNativeReviewDiffBridge(input: {
44 readonly threadKey: string | null;
45 readonly sectionId: string | null;
46 readonly diff: string | null | undefined;
47 readonly data: NativeReviewDiffData;
48 readonly scheme: NativeReviewDiffHighlightScheme;
49 readonly collapsedFileIds: ReadonlyArray<string>;
50 readonly viewedFileIds: ReadonlyArray<string>;
51 readonly selectedRowIds: ReadonlyArray<string>;
52 readonly canHighlight: boolean;
53}) {
54 const {
55 canHighlight,
56 collapsedFileIds,
57 data,
58 diff,
59 scheme,
60 sectionId,
61 selectedRowIds,
62 threadKey,
63 viewedFileIds,
64 } = input;
65 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(
66 () => new Set(),
67 );
68
69 const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]);
70 const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]);
71 const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]);
72 const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]);
73 const selectedRowIdsJson = useMemo(() => JSON.stringify(selectedRowIds), [selectedRowIds]);
74 const collapsedCommentIdsJson = useMemo(
75 () => JSON.stringify(Array.from(collapsedCommentIds)),
76 [collapsedCommentIds],
77 );
78 const themeJson = useMemo(() => JSON.stringify(theme), [theme]);
79 const styleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);
80 const tokensResetKey = useMemo(
81 () =>
82 buildNativeReviewTokensResetKey({
83 threadKey,
84 sectionId,
85 scheme,
86 diff,
87 fileCount: data.files.length,
88 rowCount: data.rows.length,
89 }),
90 [data.files.length, data.rows.length, diff, scheme, sectionId, threadKey],
91 );
92 const { tokensPatchJson, updateVisibleRange } = useNativeReviewDiffHighlighting({
93 files: data.files,
94 rows: data.rows,
95 scheme,
96 resetKey: tokensResetKey,
97 enabled: canHighlight,
98 });
99
100 const onDebug = useCallback(
101 (event: NativeSyntheticEvent<Record<string, unknown>>) => {
102 const payload = event.nativeEvent;
103 const message = payload.message;
104 if (
105 (message === "draw-metrics" || message === "visible-range") &&
106 typeof payload.firstRowIndex === "number" &&
107 typeof payload.lastRowIndex === "number"
108 ) {
109 updateVisibleRange({
110 firstRowIndex: payload.firstRowIndex,
111 lastRowIndex: payload.lastRowIndex,
112 });
113 }
114 },
115 [updateVisibleRange],
116 );
117
118 const onToggleComment = useCallback(
119 (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => {
120 const { commentId } = event.nativeEvent;
121 if (!commentId) {
122 return;
123 }
124
125 setCollapsedCommentIds((current) => {
126 const next = new Set(current);
127 if (next.has(commentId)) {
128 next.delete(commentId);
129 } else {
130 next.add(commentId);
131 }
132 return next;
133 });
134 },
135 [],
136 );
137
138 return {
139 theme,
140 rowsJson,
141 collapsedFileIdsJson,
142 collapsedCommentIdsJson,
143 viewedFileIdsJson,
144 selectedRowIdsJson,
145 themeJson,
146 styleJson,
147 tokensPatchJson,
148 tokensResetKey,
149 onDebug,
150 onToggleComment,
151 };
152}
apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts created+136
...@@ -0,0 +1,136 @@
1import { useCallback, useEffect, useRef, useState } from "react";
2
3import {
4 highlightNativeReviewDiffVisibleRows,
5 type NativeReviewDiffHighlightEngine,
6 type NativeReviewDiffHighlightScheme,
7} from "../diffs/nativeReviewDiffHighlighter";
8import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface";
9import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes";
10
11interface NativeReviewVisibleRange {
12 readonly firstRowIndex: number;
13 readonly lastRowIndex: number;
14}
15
16function createEmptyTokenPatch(resetKey: string): string {
17 return JSON.stringify({ resetKey, tokensByRowId: {} });
18}
19
20function isReviewDiffDebugLoggingEnabled(): boolean {
21 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
22}
23
24function logReviewDiffDiagnostic(message: string, details?: Record<string, unknown>): void {
25 if (!isReviewDiffDebugLoggingEnabled()) {
26 return;
27 }
28
29 if (details) {
30 console.log(`[review-sheet] ${message}`, details);
31 return;
32 }
33
34 console.log(`[review-sheet] ${message}`);
35}
36
37export function useNativeReviewDiffHighlighting(input: {
38 readonly files: ReadonlyArray<NativeReviewDiffFile>;
39 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
40 readonly scheme: NativeReviewDiffHighlightScheme;
41 readonly resetKey: string;
42 readonly enabled: boolean;
43}) {
44 const { enabled, files, resetKey, rows, scheme } = input;
45 const highlightedRowIdsRef = useRef<Set<string>>(new Set());
46 const visibleRangeRef = useRef<NativeReviewVisibleRange>({
47 firstRowIndex: 0,
48 lastRowIndex: 80,
49 });
50 const visibleChunkIndexRef = useRef(0);
51 const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey));
52 const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0);
53
54 useEffect(() => {
55 highlightedRowIdsRef.current = new Set();
56 visibleChunkIndexRef.current = 0;
57 visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 };
58 setTokensPatchJson(createEmptyTokenPatch(resetKey));
59 if (enabled && rows.length > 0) {
60 setVisibleHighlightRequest((request) => request + 1);
61 }
62 }, [enabled, resetKey, rows.length]);
63
64 useEffect(() => {
65 if (!enabled || rows.length === 0) {
66 return;
67 }
68
69 const abortController = new AbortController();
70 const requestRange = visibleRangeRef.current;
71 const engine: NativeReviewDiffHighlightEngine = "native";
72
73 void (async () => {
74 try {
75 const result = await highlightNativeReviewDiffVisibleRows({
76 files,
77 rows,
78 scheme,
79 engine,
80 firstRowIndex: requestRange.firstRowIndex,
81 lastRowIndex: requestRange.lastRowIndex,
82 alreadyHighlightedRowIds: highlightedRowIdsRef.current,
83 signal: abortController.signal,
84 });
85
86 if (abortController.signal.aborted || result.rowCount === 0) {
87 return;
88 }
89
90 for (const rowId of Object.keys(result.tokensByRowId)) {
91 highlightedRowIdsRef.current.add(rowId);
92 }
93
94 const chunkIndex = visibleChunkIndexRef.current;
95 visibleChunkIndexRef.current += 1;
96 setTokensPatchJson(
97 JSON.stringify({
98 resetKey,
99 chunkIndex,
100 fileId: "visible",
101 filePath: "visible rows",
102 language: "diff",
103 lineCount: result.rowCount,
104 durationMs: result.durationMs,
105 tokensByRowId: result.tokensByRowId,
106 }),
107 );
108 } catch (error) {
109 if (!abortController.signal.aborted) {
110 logReviewDiffDiagnostic("native visible highlight failed", {
111 error: error instanceof Error ? error.message : String(error),
112 });
113 }
114 }
115 })();
116
117 return () => abortController.abort();
118 }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]);
119
120 const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => {
121 const previousRange = visibleRangeRef.current;
122 const movedRows =
123 Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) +
124 Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex);
125
126 visibleRangeRef.current = nextRange;
127 if (movedRows >= 20) {
128 setVisibleHighlightRequest((request) => request + 1);
129 }
130 }, []);
131
132 return {
133 tokensPatchJson,
134 updateVisibleRange,
135 };
136}
apps/mobile/src/features/review/useReviewCommentSelectionController.ts created+199
...@@ -0,0 +1,199 @@
1import { useCallback, useEffect, useMemo, useState } from "react";
2import type { NativeSyntheticEvent } from "react-native";
3import { useRouter } from "expo-router";
4import * as Arr from "effect/Array";
5import { pipe } from "effect/Function";
6import * as Result from "effect/Result";
7
8import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
9
10import {
11 buildReviewCommentTarget,
12 clearReviewCommentTarget,
13 formatReviewSelectedRangeLabel,
14 getSelectedReviewCommentLines,
15 setReviewCommentTarget,
16 useReviewCommentTarget,
17} from "./reviewCommentSelection";
18import type {
19 NativeReviewDiffData,
20 NativeReviewDiffCommentTarget,
21} from "./nativeReviewDiffAdapter";
22import type { ReviewSectionItem } from "./reviewModel";
23
24interface PendingNativeCommentSelection extends NativeReviewDiffCommentTarget {
25 readonly sectionId: string;
26 readonly sectionTitle: string;
27 readonly rowId: string;
28}
29
30export function useReviewCommentSelectionController(input: {
31 readonly environmentId?: EnvironmentId;
32 readonly threadId?: ThreadId;
33 readonly selectedSection: ReviewSectionItem | null;
34 readonly nativeReviewDiffData: NativeReviewDiffData;
35}) {
36 const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input;
37 const { push } = useRouter();
38 const activeCommentTarget = useReviewCommentTarget();
39 const [pendingNativeCommentSelection, setPendingNativeCommentSelection] =
40 useState<PendingNativeCommentSelection | null>(null);
41
42 const openReviewCommentSheet = useCallback(() => {
43 if (!environmentId || !threadId) {
44 return;
45 }
46
47 push({
48 pathname: "/threads/[environmentId]/[threadId]/review-comment",
49 params: { environmentId, threadId },
50 });
51 }, [environmentId, push, threadId]);
52
53 const selectedRowIds = useMemo(() => {
54 if (
55 activeCommentTarget &&
56 activeCommentTarget.sectionTitle === selectedSection?.title &&
57 activeCommentTarget.startIndex !== activeCommentTarget.endIndex
58 ) {
59 return pipe(
60 getSelectedReviewCommentLines(activeCommentTarget),
61 Arr.filterMap((line) => {
62 const rowId = nativeReviewDiffData.rowIdByCommentLineId.get(line.id);
63 return rowId ? Result.succeed(rowId) : Result.failVoid;
64 }),
65 );
66 }
67
68 return pendingNativeCommentSelection ? [pendingNativeCommentSelection.rowId] : [];
69 }, [
70 activeCommentTarget,
71 nativeReviewDiffData.rowIdByCommentLineId,
72 pendingNativeCommentSelection,
73 selectedSection?.title,
74 ]);
75
76 const selectionAction = useMemo(() => {
77 if (
78 activeCommentTarget &&
79 activeCommentTarget.sectionTitle === selectedSection?.title &&
80 activeCommentTarget.startIndex !== activeCommentTarget.endIndex
81 ) {
82 return {
83 title: `Comment on ${formatReviewSelectedRangeLabel(activeCommentTarget)}`,
84 onOpenComment: openReviewCommentSheet,
85 };
86 }
87
88 if (
89 pendingNativeCommentSelection &&
90 pendingNativeCommentSelection.sectionTitle === selectedSection?.title
91 ) {
92 return {
93 title: "Select range end",
94 onOpenComment: null,
95 };
96 }
97
98 return null;
99 }, [
100 activeCommentTarget,
101 openReviewCommentSheet,
102 pendingNativeCommentSelection,
103 selectedSection?.title,
104 ]);
105
106 useEffect(() => {
107 clearReviewCommentTarget();
108 setPendingNativeCommentSelection(null);
109 }, [selectedSection?.id]);
110
111 useEffect(() => {
112 if (activeCommentTarget === null) {
113 setPendingNativeCommentSelection(null);
114 }
115 }, [activeCommentTarget]);
116
117 const onPressLine = useCallback(
118 (
119 event: NativeSyntheticEvent<{
120 readonly rowId?: string;
121 readonly gesture?: "tap" | "longPress";
122 }>,
123 ) => {
124 if (!selectedSection) {
125 return;
126 }
127
128 const { rowId, gesture } = event.nativeEvent;
129 if (!rowId) {
130 return;
131 }
132
133 const target = nativeReviewDiffData.commentTargetsByRowId.get(rowId);
134 if (!target) {
135 return;
136 }
137
138 if (gesture === "longPress") {
139 clearReviewCommentTarget();
140 setPendingNativeCommentSelection({
141 ...target,
142 sectionId: selectedSection.id,
143 sectionTitle: selectedSection.title,
144 rowId,
145 });
146 return;
147 }
148
149 if (
150 pendingNativeCommentSelection &&
151 pendingNativeCommentSelection.sectionTitle === selectedSection.title &&
152 pendingNativeCommentSelection.filePath === target.filePath
153 ) {
154 setReviewCommentTarget(
155 buildReviewCommentTarget(
156 {
157 sectionTitle: pendingNativeCommentSelection.sectionTitle,
158 sectionId: pendingNativeCommentSelection.sectionId,
159 filePath: pendingNativeCommentSelection.filePath,
160 lines: pendingNativeCommentSelection.lines,
161 },
162 pendingNativeCommentSelection.lineIndex,
163 target.lineIndex,
164 ),
165 );
166 return;
167 }
168
169 setPendingNativeCommentSelection(null);
170 setReviewCommentTarget({
171 sectionTitle: selectedSection.title,
172 sectionId: selectedSection.id,
173 filePath: target.filePath,
174 lines: target.lines,
175 startIndex: target.lineIndex,
176 endIndex: target.lineIndex,
177 });
178 openReviewCommentSheet();
179 },
180 [
181 nativeReviewDiffData.commentTargetsByRowId,
182 openReviewCommentSheet,
183 pendingNativeCommentSelection,
184 selectedSection,
185 ],
186 );
187
188 const clearSelection = useCallback(() => {
189 clearReviewCommentTarget();
190 setPendingNativeCommentSelection(null);
191 }, []);
192
193 return {
194 selectedRowIds,
195 selectionAction,
196 onPressLine,
197 clearSelection,
198 };
199}
apps/mobile/src/features/review/useReviewDiffData.ts created+109
...@@ -0,0 +1,109 @@
1import { useEffect, useMemo } from "react";
2
3import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";
4import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter";
5import { markReviewEvent, measureReviewWork } from "./reviewPerf";
6import { getCachedReviewParsedDiff } from "./reviewState";
7import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel";
8
9function isReviewDiffDebugLoggingEnabled(): boolean {
10 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
11}
12
13function logReviewDiffDiagnostic(message: string, details?: Record<string, unknown>): void {
14 if (!isReviewDiffDebugLoggingEnabled()) {
15 return;
16 }
17
18 if (details) {
19 console.log(`[review-sheet] ${message}`, details);
20 return;
21 }
22
23 console.log(`[review-sheet] ${message}`);
24}
25
26export function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): {
27 readonly additions: string | null;
28 readonly deletions: string | null;
29} {
30 if (parsedDiff.kind !== "files") {
31 return { additions: null, deletions: null };
32 }
33
34 return {
35 additions: `+${parsedDiff.additions}`,
36 deletions: `-${parsedDiff.deletions}`,
37 };
38}
39
40export function useReviewDiffData(input: {
41 readonly threadKey: string | null;
42 readonly selectedSection: ReviewSectionItem | null;
43 readonly draftMessage: string;
44}) {
45 const { draftMessage, selectedSection, threadKey } = input;
46 const parsedDiff = useMemo(
47 () =>
48 measureReviewWork("parse-diff", () =>
49 getCachedReviewParsedDiff({
50 threadKey,
51 sectionId: selectedSection?.id ?? null,
52 diff: selectedSection?.diff,
53 }),
54 ),
55 [selectedSection?.diff, selectedSection?.id, threadKey],
56 );
57 const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]);
58 const inlineReviewComments = useMemo(
59 () => parseReviewInlineComments(draftMessage),
60 [draftMessage],
61 );
62 const selectedSectionInlineComments = useMemo(
63 () =>
64 selectedSection
65 ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id)
66 : [],
67 [inlineReviewComments, selectedSection],
68 );
69 const nativeReviewDiffData = useMemo(
70 () =>
71 measureReviewWork("build-native-diff-data", () =>
72 buildNativeReviewDiffData({
73 parsedDiff,
74 comments: selectedSectionInlineComments,
75 }),
76 ),
77 [parsedDiff, selectedSectionInlineComments],
78 );
79 const pendingReviewCommentCount = useMemo(
80 () => countReviewCommentContexts(draftMessage),
81 [draftMessage],
82 );
83
84 useEffect(() => {
85 if (parsedDiff.kind !== "files") {
86 return;
87 }
88
89 markReviewEvent("parsed-diff-ready", {
90 sectionId: selectedSection?.id ?? null,
91 fileCount: parsedDiff.fileCount,
92 additions: parsedDiff.additions,
93 deletions: parsedDiff.deletions,
94 renderedItems: nativeReviewDiffData.rows.length,
95 });
96 logReviewDiffDiagnostic("parsed diff files", {
97 selectedSectionId: selectedSection?.id ?? null,
98 fileCount: parsedDiff.fileCount,
99 renderableFileCount: parsedDiff.files.length,
100 });
101 }, [nativeReviewDiffData.rows.length, parsedDiff, selectedSection?.id]);
102
103 return {
104 parsedDiff,
105 headerDiffSummary,
106 nativeReviewDiffData,
107 pendingReviewCommentCount,
108 };
109}
apps/mobile/src/features/review/useReviewSections.ts created+252
...@@ -0,0 +1,252 @@
1import { useCallback, useEffect, useMemo, useRef } from "react";
2
3import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
4
5import { getEnvironmentClient } from "../../state/environment-session-registry";
6import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff";
7import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
8import { useSelectedThreadDetail } from "../../state/use-thread-detail";
9import { useReviewDiffPreview } from "./reviewDiffPreviewState";
10import {
11 buildReviewSectionItems,
12 getDefaultReviewSectionId,
13 getReadyReviewCheckpoints,
14 getReviewSectionIdForCheckpoint,
15} from "./reviewModel";
16import {
17 setReviewAsyncError,
18 setReviewGitSections,
19 setReviewSelectedSectionId,
20 setReviewTurnDiffLoading,
21 setReviewTurnDiff,
22 type ReviewCacheForThread,
23} from "./reviewState";
24
25export function useReviewSections(input: {
26 readonly environmentId?: EnvironmentId;
27 readonly threadId?: ThreadId;
28 readonly reviewCache: ReviewCacheForThread;
29}) {
30 const { environmentId, reviewCache, threadId } = input;
31 const selectedThread = useSelectedThreadDetail();
32 const { selectedThreadCwd } = useSelectedThreadWorktree();
33 const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd });
34 const refreshDiffPreview = diffPreview.refresh;
35 const { loadingTurnIds } = reviewCache.asyncState;
36 const error = diffPreview.error ?? reviewCache.asyncState.error;
37 const loadingGitDiffs = diffPreview.isPending;
38 const turnDiffByIdRef = useRef(reviewCache.turnDiffById);
39
40 useEffect(() => {
41 turnDiffByIdRef.current = reviewCache.turnDiffById;
42 }, [reviewCache.turnDiffById]);
43
44 useEffect(() => {
45 if (reviewCache.threadKey && diffPreview.data) {
46 setReviewGitSections(reviewCache.threadKey, diffPreview.data.sources);
47 }
48 }, [diffPreview.data, reviewCache.threadKey]);
49
50 const readyCheckpoints = useMemo(
51 () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []),
52 [selectedThread?.checkpoints],
53 );
54 const checkpointBySectionId = useMemo(() => {
55 return Object.fromEntries(
56 readyCheckpoints.map((checkpoint) => [
57 getReviewSectionIdForCheckpoint(checkpoint),
58 checkpoint,
59 ]),
60 ) as Record<string, OrchestrationCheckpointSummary>;
61 }, [readyCheckpoints]);
62 const reviewSections = useMemo(
63 () =>
64 buildReviewSectionItems({
65 checkpoints: readyCheckpoints,
66 gitSections: reviewCache.gitSections,
67 turnDiffById: reviewCache.turnDiffById,
68 loadingTurnIds,
69 loadingGitSections: diffPreview.isPending,
70 }),
71 [
72 diffPreview.isPending,
73 loadingTurnIds,
74 readyCheckpoints,
75 reviewCache.gitSections,
76 reviewCache.turnDiffById,
77 ],
78 );
79 const selectedSection = useMemo(
80 () =>
81 reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ??
82 reviewSections[0] ??
83 null,
84 [reviewCache.selectedSectionId, reviewSections],
85 );
86 const fallbackSectionId = useMemo(
87 () => getDefaultReviewSectionId(reviewSections),
88 [reviewSections],
89 );
90 const hasReviewSections = reviewSections.length > 0;
91 const selectedSectionIdExists = useMemo(
92 () =>
93 reviewCache.selectedSectionId
94 ? reviewSections.some((section) => section.id === reviewCache.selectedSectionId)
95 : false,
96 [reviewCache.selectedSectionId, reviewSections],
97 );
98
99 const loadTurnDiff = useCallback(
100 async (checkpoint: OrchestrationCheckpointSummary, force = false) => {
101 if (!environmentId || !threadId) {
102 return;
103 }
104
105 const sectionId = getReviewSectionIdForCheckpoint(checkpoint);
106 if (reviewCache.threadKey) {
107 setReviewSelectedSectionId(reviewCache.threadKey, sectionId);
108 }
109
110 if (!force && turnDiffByIdRef.current[sectionId] !== undefined) {
111 return;
112 }
113
114 const target = {
115 environmentId,
116 threadId,
117 fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1),
118 toTurnCount: checkpoint.checkpointTurnCount,
119 ignoreWhitespace: false,
120 cacheScope: sectionId,
121 };
122 const cached = checkpointDiffManager.getSnapshot(target).data;
123 if (!force && cached) {
124 if (reviewCache.threadKey) {
125 setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff);
126 }
127 return;
128 }
129
130 if (!getEnvironmentClient(environmentId)) {
131 if (reviewCache.threadKey) {
132 setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready.");
133 }
134 return;
135 }
136
137 if (reviewCache.threadKey) {
138 setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true);
139 setReviewAsyncError(reviewCache.threadKey, null);
140 }
141 try {
142 const result = await loadCheckpointDiff(target, { force });
143 if (reviewCache.threadKey) {
144 if (result) {
145 setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);
146 }
147 }
148 } catch (cause) {
149 if (reviewCache.threadKey) {
150 setReviewAsyncError(
151 reviewCache.threadKey,
152 cause instanceof Error ? cause.message : "Failed to load turn diff.",
153 );
154 }
155 } finally {
156 if (reviewCache.threadKey) {
157 setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false);
158 }
159 }
160 },
161 [environmentId, reviewCache.threadKey, threadId],
162 );
163
164 useEffect(() => {
165 if (!hasReviewSections) {
166 return;
167 }
168
169 if (reviewCache.threadKey && (!reviewCache.selectedSectionId || !selectedSectionIdExists)) {
170 setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId);
171 }
172 }, [
173 fallbackSectionId,
174 hasReviewSections,
175 reviewCache.selectedSectionId,
176 reviewCache.threadKey,
177 selectedSectionIdExists,
178 ]);
179
180 const latestCheckpoint = readyCheckpoints[0] ?? null;
181 const latestSectionId = latestCheckpoint
182 ? getReviewSectionIdForCheckpoint(latestCheckpoint)
183 : null;
184 const latestTurnDiffLoaded = latestSectionId
185 ? reviewCache.turnDiffById[latestSectionId] !== undefined
186 : true;
187 const latestTurnDiffLoading = latestSectionId ? loadingTurnIds[latestSectionId] === true : false;
188
189 useEffect(() => {
190 if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) {
191 return;
192 }
193
194 void loadTurnDiff(latestCheckpoint);
195 }, [
196 latestCheckpoint,
197 latestSectionId,
198 latestTurnDiffLoaded,
199 latestTurnDiffLoading,
200 loadTurnDiff,
201 ]);
202
203 const selectedTurnCheckpoint =
204 selectedSection?.kind === "turn" ? (checkpointBySectionId[selectedSection.id] ?? null) : null;
205 const selectedTurnDiffMissing =
206 selectedSection?.kind === "turn" && selectedSection.diff === null && selectedTurnCheckpoint;
207 const selectedTurnDiffLoading =
208 selectedSection?.kind === "turn" ? loadingTurnIds[selectedSection.id] === true : false;
209
210 useEffect(() => {
211 if (!selectedTurnDiffMissing || selectedTurnDiffLoading) {
212 return;
213 }
214
215 void loadTurnDiff(selectedTurnDiffMissing);
216 }, [loadTurnDiff, selectedTurnDiffLoading, selectedTurnDiffMissing]);
217
218 const refreshSelectedSection = useCallback(async () => {
219 if (!selectedSection) {
220 return;
221 }
222
223 if (selectedSection.kind === "turn") {
224 const checkpoint = checkpointBySectionId[selectedSection.id];
225 if (checkpoint) {
226 await loadTurnDiff(checkpoint, true);
227 }
228 return;
229 }
230
231 refreshDiffPreview();
232 }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]);
233
234 const selectSection = useCallback(
235 (sectionId: string) => {
236 if (reviewCache.threadKey) {
237 setReviewSelectedSectionId(reviewCache.threadKey, sectionId);
238 }
239 },
240 [reviewCache.threadKey],
241 );
242
243 return {
244 error,
245 loadingGitDiffs,
246 loadingTurnIds,
247 reviewSections,
248 selectedSection,
249 refreshSelectedSection,
250 selectSection,
251 };
252}
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx created+271
...@@ -0,0 +1,271 @@
1import { memo, useCallback, useEffect, useRef } from "react";
2import {
3 Pressable,
4 ScrollView,
5 TextInput,
6 View,
7 type LayoutChangeEvent,
8 type NativeSyntheticEvent,
9 type ViewProps,
10 useColorScheme,
11} from "react-native";
12
13import { AppText as Text } from "../../components/AppText";
14import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";
15import {
16 buildGhosttyThemeConfig,
17 getPierreTerminalTheme,
18 type TerminalTheme,
19} from "./terminalTheme";
20import { terminalDebugLog } from "./terminalDebugLog";
21
22interface TerminalInputEvent {
23 readonly data: string;
24}
25
26interface TerminalResizeEvent {
27 readonly cols: number;
28 readonly rows: number;
29}
30
31interface TerminalSurfaceProps extends ViewProps {
32 readonly terminalKey: string;
33 readonly buffer: string;
34 readonly fontSize?: number;
35 readonly isRunning: boolean;
36 readonly keyboardFocusRequest?: number;
37 readonly theme?: TerminalTheme;
38 readonly onInput: (data: string) => void;
39 readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;
40}
41
42function estimateGridSize(input: {
43 readonly width: number;
44 readonly height: number;
45 readonly fontSize: number;
46}): { readonly cols: number; readonly rows: number } {
47 const cellWidth = input.fontSize * 0.62;
48 const cellHeight = input.fontSize * 1.35;
49 return {
50 cols: Math.max(20, Math.min(400, Math.floor(input.width / cellWidth))),
51 rows: Math.max(5, Math.min(200, Math.floor(input.height / cellHeight))),
52 };
53}
54
55const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
56 const fontSize = props.fontSize ?? 12;
57 const inputRef = useRef<TextInput>(null);
58 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
59 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
60 const statusLabel = props.isRunning
61 ? "Native terminal unavailable. Using text fallback."
62 : "Open terminal to start a shell.";
63
64 const handleLayout = (event: LayoutChangeEvent) => {
65 const { width, height } = event.nativeEvent.layout;
66 props.onResize(estimateGridSize({ width, height, fontSize }));
67 };
68
69 useEffect(() => {
70 if ((props.keyboardFocusRequest ?? 0) > 0) {
71 inputRef.current?.blur();
72 const focusFrame = requestAnimationFrame(() => inputRef.current?.focus());
73 return () => cancelAnimationFrame(focusFrame);
74 }
75
76 return undefined;
77 }, [props.keyboardFocusRequest]);
78
79 return (
80 <View
81 style={[
82 {
83 flex: 1,
84 backgroundColor: theme.background,
85 borderRadius: 8,
86 overflow: "hidden",
87 },
88 props.style,
89 ]}
90 onLayout={handleLayout}
91 >
92 <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>
93 <Text
94 style={{
95 color: theme.mutedForeground,
96 fontSize: 11,
97 paddingBottom: 8,
98 }}
99 >
100 {statusLabel}
101 </Text>
102 <ScrollView
103 style={{ flex: 1 }}
104 contentContainerStyle={{ paddingBottom: 12 }}
105 showsVerticalScrollIndicator={false}
106 >
107 <Text
108 selectable
109 style={{
110 color: theme.foreground,
111 fontFamily: "Menlo",
112 fontSize,
113 lineHeight: Math.round(fontSize * 1.35),
114 }}
115 >
116 {props.buffer || "$ "}
117 </Text>
118 </ScrollView>
119 </View>
120 <View
121 style={{
122 borderTopWidth: 1,
123 borderTopColor: theme.border,
124 flexDirection: "row",
125 alignItems: "center",
126 gap: 8,
127 padding: 8,
128 }}
129 >
130 <TextInput
131 ref={inputRef}
132 autoCapitalize="none"
133 autoCorrect={false}
134 blurOnSubmit={false}
135 editable={props.isRunning}
136 placeholder="type and press return"
137 placeholderTextColor={theme.mutedForeground}
138 returnKeyType="send"
139 style={{
140 color: theme.foreground,
141 flex: 1,
142 fontFamily: "Menlo",
143 fontSize: 13,
144 padding: 0,
145 }}
146 onSubmitEditing={(event) => {
147 const text = event.nativeEvent.text;
148 if (text.length > 0) {
149 props.onInput(`${text}\n`);
150 }
151 }}
152 />
153 <Pressable
154 disabled={!props.isRunning}
155 style={({ pressed }) => ({
156 opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1,
157 paddingHorizontal: 10,
158 paddingVertical: 6,
159 borderRadius: 8,
160 backgroundColor: theme.border,
161 })}
162 onPress={() => props.onInput("\u0003")}
163 >
164 <Text
165 style={{
166 color: theme.foreground,
167 fontFamily: "DMSans_700Bold",
168 fontSize: 11,
169 }}
170 >
171 Ctrl-C
172 </Text>
173 </Pressable>
174 </View>
175 </View>
176 );
177});
178
179export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
180 const fontSize = props.fontSize ?? 12;
181 const keyboardInputRef = useRef<TextInput>(null);
182 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
183 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
184 const { onInput, onResize } = props;
185 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
186 const hasNativeSurface = Boolean(NativeTerminalSurfaceView);
187
188 useEffect(() => {
189 terminalDebugLog("native:surface", {
190 terminalKey: props.terminalKey,
191 native: hasNativeSurface,
192 bufferLen: props.buffer.length,
193 isRunning: props.isRunning,
194 });
195 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
196 const handleNativeInput = useCallback(
197 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
198 onInput(event.nativeEvent.data);
199 },
200 [onInput],
201 );
202 const handleNativeResize = useCallback(
203 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
204 onResize({
205 cols: event.nativeEvent.cols,
206 rows: event.nativeEvent.rows,
207 });
208 },
209 [onResize],
210 );
211
212 // Reopen focus through React and forward input normally; this avoids a native focus-command bridge.
213 useEffect(() => {
214 if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) {
215 return undefined;
216 }
217
218 keyboardInputRef.current?.blur();
219 const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus());
220 return () => cancelAnimationFrame(focusFrame);
221 }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]);
222
223 const handleKeyboardInput = useCallback(
224 (data: string) => {
225 if (data.length > 0) {
226 onInput(data);
227 keyboardInputRef.current?.clear();
228 }
229 },
230 [onInput],
231 );
232
233 if (NativeTerminalSurfaceView) {
234 return (
235 <View style={props.style}>
236 <NativeTerminalSurfaceView
237 appearanceScheme={appearanceScheme}
238 backgroundColor={theme.background}
239 foregroundColor={theme.foreground}
240 mutedForegroundColor={theme.mutedForeground}
241 terminalKey={props.terminalKey}
242 initialBuffer={props.buffer}
243 fontSize={fontSize}
244 style={{ flex: 1 }}
245 themeConfig={buildGhosttyThemeConfig(theme)}
246 onInput={handleNativeInput}
247 onResize={handleNativeResize}
248 />
249 <TextInput
250 ref={keyboardInputRef}
251 autoCapitalize="none"
252 autoCorrect={false}
253 blurOnSubmit={false}
254 caretHidden
255 editable={props.isRunning}
256 keyboardType="ascii-capable"
257 style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }}
258 onChangeText={handleKeyboardInput}
259 onKeyPress={(event) => {
260 if (event.nativeEvent.key === "Backspace") {
261 onInput("\u007f");
262 }
263 }}
264 onSubmitEditing={() => onInput("\n")}
265 />
266 </View>
267 );
268 }
269
270 return <FallbackTerminalSurface {...props} fontSize={fontSize} theme={theme} />;
271});
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx created+175
...@@ -0,0 +1,175 @@
1import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";
3import { memo, useCallback, useEffect, useRef, useState } from "react";
4import { Pressable, View } from "react-native";
5
6import { AppText as Text } from "../../components/AppText";
7import { getEnvironmentClient } from "../../state/environment-session-registry";
8import {
9 attachTerminalSession,
10 useTerminalSession,
11 useTerminalSessionTarget,
12} from "../../state/use-terminal-session";
13import { TerminalSurface } from "./NativeTerminalSurface";
14import { hasNativeTerminalSurface } from "./nativeTerminalModule";
15import { terminalDebugLog } from "./terminalDebugLog";
16
17interface ThreadTerminalPanelProps {
18 readonly environmentId: EnvironmentId;
19 readonly threadId: ThreadId;
20 readonly cwd: string;
21 readonly worktreePath: string | null;
22 readonly visible: boolean;
23 readonly onClose: () => void;
24}
25
26const DEFAULT_TERMINAL_COLS = 80;
27const DEFAULT_TERMINAL_ROWS = 24;
28
29export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
30 props: ThreadTerminalPanelProps,
31) {
32 const nativeTerminalAvailable = hasNativeTerminalSurface();
33 const terminalId = DEFAULT_TERMINAL_ID;
34 const target = useTerminalSessionTarget({
35 environmentId: props.environmentId,
36 threadId: props.threadId,
37 terminalId,
38 });
39 const terminal = useTerminalSession(target);
40 const [lastGridSize, setLastGridSize] = useState({
41 cols: DEFAULT_TERMINAL_COLS,
42 rows: DEFAULT_TERMINAL_ROWS,
43 });
44 const lastGridSizeRef = useRef(lastGridSize);
45 lastGridSizeRef.current = lastGridSize;
46
47 const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`;
48 const isRunning = terminal.status === "running" || terminal.status === "starting";
49
50 useEffect(() => {
51 if (!props.visible) {
52 return;
53 }
54
55 const client = getEnvironmentClient(props.environmentId);
56 if (!client) {
57 terminalDebugLog("panel:attach-skip", {
58 reason: "no-environment-client",
59 environmentId: props.environmentId,
60 });
61 return;
62 }
63
64 terminalDebugLog("panel:attach", {
65 environmentId: props.environmentId,
66 threadId: props.threadId,
67 terminalId,
68 });
69
70 return attachTerminalSession({
71 environmentId: props.environmentId,
72 client,
73 terminal: {
74 threadId: props.threadId,
75 terminalId,
76 cwd: props.cwd,
77 worktreePath: props.worktreePath,
78 cols: lastGridSizeRef.current.cols,
79 rows: lastGridSizeRef.current.rows,
80 },
81 });
82 }, [
83 props.cwd,
84 props.environmentId,
85 props.threadId,
86 props.worktreePath,
87 props.visible,
88 terminalId,
89 ]);
90
91 const handleInput = useCallback(
92 (data: string) => {
93 const client = getEnvironmentClient(props.environmentId);
94 if (!client || !isRunning) {
95 return;
96 }
97
98 void client.terminal.write({
99 threadId: props.threadId,
100 terminalId,
101 data,
102 });
103 },
104 [isRunning, props.environmentId, props.threadId, terminalId],
105 );
106
107 const handleResize = useCallback(
108 (size: { readonly cols: number; readonly rows: number }) => {
109 if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) {
110 return;
111 }
112
113 setLastGridSize(size);
114 const client = getEnvironmentClient(props.environmentId);
115 if (!client || !isRunning) {
116 return;
117 }
118
119 void client.terminal.resize({
120 threadId: props.threadId,
121 terminalId,
122 cols: size.cols,
123 rows: size.rows,
124 });
125 },
126 [
127 isRunning,
128 lastGridSize.cols,
129 lastGridSize.rows,
130 props.environmentId,
131 props.threadId,
132 terminalId,
133 ],
134 );
135
136 if (!props.visible) {
137 return null;
138 }
139
140 return (
141 <View className="absolute inset-x-3 bottom-28 top-28 overflow-hidden rounded-[8px] border border-white/10 bg-neutral-950 shadow-2xl">
142 <View className="flex-row items-center justify-between border-b border-white/10 px-3 py-2">
143 <View className="min-w-0 flex-1">
144 <Text className="font-t3-bold text-[13px] text-neutral-100" numberOfLines={1}>
145 Terminal
146 </Text>
147 <Text className="text-[11px] text-neutral-500" numberOfLines={1}>
148 {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"}
149 </Text>
150 </View>
151 <View className="flex-row items-center gap-2">
152 {terminal.error ? (
153 <Text className="max-w-44 text-right text-[11px] text-red-300" numberOfLines={1}>
154 {terminal.error}
155 </Text>
156 ) : null}
157 <Pressable
158 className="h-8 w-8 items-center justify-center rounded-[8px] bg-white/10"
159 onPress={props.onClose}
160 >
161 <SymbolView name="xmark" size={13} tintColor="#e5e5e5" type="monochrome" />
162 </Pressable>
163 </View>
164 </View>
165 <TerminalSurface
166 terminalKey={terminalKey}
167 buffer={terminal.buffer}
168 isRunning={isRunning}
169 onInput={handleInput}
170 onResize={handleResize}
171 style={{ flex: 1 }}
172 />
173 </View>
174 );
175});
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.test.ts created+108
...@@ -0,0 +1,108 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap";
4
5describe("resolveTerminalRouteBootstrap", () => {
6 it("redirects bare terminal routes to another already-running terminal for the thread", () => {
7 expect(
8 resolveTerminalRouteBootstrap({
9 hasThread: true,
10 hasWorkspaceRoot: true,
11 hasOpened: false,
12 requestedTerminalId: null,
13 currentTerminalId: "default",
14 runningTerminalId: "term-2",
15 currentTerminalStatus: "closed",
16 hasCurrentTerminalHydration: false,
17 }),
18 ).toEqual({
19 kind: "redirect",
20 terminalId: "term-2",
21 });
22 });
23
24 it("hydrates the current running terminal when client state is not hydrated yet", () => {
25 expect(
26 resolveTerminalRouteBootstrap({
27 hasThread: true,
28 hasWorkspaceRoot: true,
29 hasOpened: false,
30 requestedTerminalId: null,
31 currentTerminalId: "default",
32 runningTerminalId: "default",
33 currentTerminalStatus: "running",
34 hasCurrentTerminalHydration: false,
35 }),
36 ).toEqual({
37 kind: "open",
38 });
39 });
40
41 it("opens explicit terminal routes when the session still needs hydration", () => {
42 expect(
43 resolveTerminalRouteBootstrap({
44 hasThread: true,
45 hasWorkspaceRoot: true,
46 hasOpened: false,
47 requestedTerminalId: "term-2",
48 currentTerminalId: "term-2",
49 runningTerminalId: "term-2",
50 currentTerminalStatus: "running",
51 hasCurrentTerminalHydration: false,
52 }),
53 ).toEqual({
54 kind: "open",
55 });
56 });
57
58 it("stays idle after the route already bootstrapped once", () => {
59 expect(
60 resolveTerminalRouteBootstrap({
61 hasThread: true,
62 hasWorkspaceRoot: true,
63 hasOpened: true,
64 requestedTerminalId: null,
65 currentTerminalId: "default",
66 runningTerminalId: "default",
67 currentTerminalStatus: "running",
68 hasCurrentTerminalHydration: true,
69 }),
70 ).toEqual({
71 kind: "idle",
72 });
73 });
74
75 it("stays idle when the current running terminal is already hydrated in client state", () => {
76 expect(
77 resolveTerminalRouteBootstrap({
78 hasThread: true,
79 hasWorkspaceRoot: true,
80 hasOpened: false,
81 requestedTerminalId: null,
82 currentTerminalId: "default",
83 runningTerminalId: "default",
84 currentTerminalStatus: "running",
85 hasCurrentTerminalHydration: true,
86 }),
87 ).toEqual({
88 kind: "idle",
89 });
90 });
91
92 it("stays idle for explicit running terminal routes that already have hydrated output", () => {
93 expect(
94 resolveTerminalRouteBootstrap({
95 hasThread: true,
96 hasWorkspaceRoot: true,
97 hasOpened: false,
98 requestedTerminalId: "term-2",
99 currentTerminalId: "term-2",
100 runningTerminalId: "term-2",
101 currentTerminalStatus: "running",
102 hasCurrentTerminalHydration: true,
103 }),
104 ).toEqual({
105 kind: "idle",
106 });
107 });
108});
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx created+1334
...@@ -0,0 +1,1334 @@
1import {
2 DEFAULT_TERMINAL_ID,
3 EnvironmentId,
4 type TerminalAttachStreamEvent,
5 ThreadId,
6} from "@t3tools/contracts";
7import type { KnownTerminalSession } from "@t3tools/client-runtime";
8import { SymbolView } from "expo-symbols";
9import { Stack, useLocalSearchParams, useRouter } from "expo-router";
10import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11import {
12 Pressable,
13 ScrollView,
14 Text as RNText,
15 View,
16 useColorScheme,
17 type LayoutChangeEvent,
18 type NativeScrollEvent,
19 type NativeSyntheticEvent,
20} from "react-native";
21import {
22 KeyboardController,
23 KeyboardEvents,
24 KeyboardStickyView,
25 useKeyboardState,
26} from "react-native-keyboard-controller";
27
28import { EmptyState } from "../../components/EmptyState";
29import { GlassSurface } from "../../components/GlassSurface";
30import { LoadingScreen } from "../../components/LoadingScreen";
31import { buildThreadTerminalNavigation } from "../../lib/routes";
32import { getEnvironmentClient } from "../../state/environment-session-registry";
33import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
34import {
35 attachTerminalSession,
36 useKnownTerminalSessions,
37 useTerminalSession,
38 useTerminalSessionTarget,
39} from "../../state/use-terminal-session";
40import { useThreadSelection } from "../../state/use-thread-selection";
41import { useSelectedThreadDetail } from "../../state/use-thread-detail";
42import { TerminalSurface } from "./NativeTerminalSurface";
43import { getPierreTerminalTheme } from "./terminalTheme";
44import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
45import { terminalDebugLog } from "./terminalDebugLog";
46import {
47 getTerminalBufferReplayKey,
48 getTerminalSurfaceReplayBuffer,
49 TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
50} from "./terminalBufferReplay";
51import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap";
52import {
53 resolveTerminalOpenLocation,
54 stagePendingTerminalLaunch,
55 takePendingTerminalLaunch,
56} from "./terminalLaunchContext";
57import {
58 basename,
59 buildTerminalMenuSessions,
60 getTerminalStatusLabel,
61 nextOpenTerminalId,
62 resolveTerminalSessionLabel,
63 type TerminalMenuSession,
64} from "./terminalMenu";
65import {
66 DEFAULT_TERMINAL_FONT_SIZE,
67 MAX_TERMINAL_FONT_SIZE,
68 MIN_TERMINAL_FONT_SIZE,
69 TERMINAL_FONT_SIZE_STEP,
70 normalizeTerminalFontSize,
71} from "./terminalPreferences";
72import {
73 cacheTerminalFontSize,
74 cacheTerminalGridSize,
75 getCachedTerminalFontSize,
76 getCachedTerminalGridSize,
77} from "./terminalUiState";
78
79const DEFAULT_TERMINAL_COLS = 80;
80const DEFAULT_TERMINAL_ROWS = 24;
81const TERMINAL_ACCESSORY_HEIGHT = 52;
82const TERMINAL_ACCESSORY_FADE_WIDTH = 18;
83
84type PendingModifier = "ctrl" | "meta";
85type HostPlatform = "mac" | "linux" | "windows" | "unknown";
86
87type TerminalToolbarAction =
88 | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string }
89 | { readonly kind: "clear"; readonly key: string; readonly label: string }
90 | {
91 readonly kind: "modifier";
92 readonly key: string;
93 readonly label: string;
94 readonly modifier: PendingModifier;
95 };
96
97function getTerminalStatusTone(input: {
98 readonly status: TerminalMenuSession["status"];
99 readonly hasRunningSubprocess?: boolean;
100}): {
101 readonly tintColor: string;
102 readonly textColor: string;
103} {
104 if (input.status === "running") {
105 if (input.hasRunningSubprocess) {
106 return {
107 tintColor: "#fbbf24",
108 textColor: "#a3a3a3",
109 };
110 }
111
112 return {
113 tintColor: "#34d399",
114 textColor: "#a3a3a3",
115 };
116 }
117
118 if (input.status === "starting") {
119 return {
120 tintColor: "#f59e0b",
121 textColor: "#a3a3a3",
122 };
123 }
124
125 if (input.status === "error") {
126 return {
127 tintColor: "#ef4444",
128 textColor: "#fca5a5",
129 };
130 }
131
132 return {
133 tintColor: "#ef4444",
134 textColor: "#a3a3a3",
135 };
136}
137
138function firstRouteParam(value: string | string[] | undefined): string | null {
139 if (Array.isArray(value)) {
140 return value[0] ?? null;
141 }
142
143 return value ?? null;
144}
145
146function inferHostPlatform(environmentLabel: string | null): HostPlatform {
147 const value = environmentLabel?.toLowerCase() ?? "";
148 if (
149 value.includes("mac") ||
150 value.includes("macbook") ||
151 value.includes("mac mini") ||
152 value.includes("imac") ||
153 value.includes("darwin")
154 ) {
155 return "mac";
156 }
157 if (value.includes("windows") || value.includes("win")) {
158 return "windows";
159 }
160 if (value.includes("linux") || value.includes("ubuntu") || value.includes("debian")) {
161 return "linux";
162 }
163
164 return "unknown";
165}
166
167function applyCtrlModifier(input: string): string {
168 const firstCharacter = input[0];
169 if (!firstCharacter) {
170 return input;
171 }
172
173 const lowerCharacter = firstCharacter.toLowerCase();
174 if (lowerCharacter >= "a" && lowerCharacter <= "z") {
175 return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96);
176 }
177
178 if (firstCharacter === "@") return "\u0000";
179 if (firstCharacter === "[") return "\u001b";
180 if (firstCharacter === "\\") return "\u001c";
181 if (firstCharacter === "]") return "\u001d";
182 if (firstCharacter === "^") return "\u001e";
183 if (firstCharacter === "_") return "\u001f";
184 if (firstCharacter === "?") return "\u007f";
185
186 return input;
187}
188
189function withAlpha(hexColor: string, alpha: string): string {
190 return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor;
191}
192
193function pickRunningTerminalSessionForBootstrap(
194 sessions: ReadonlyArray<KnownTerminalSession>,
195): KnownTerminalSession | null {
196 const running = sessions.filter(
197 (session) => session.state.status === "running" || session.state.status === "starting",
198 );
199 if (running.length === 0) {
200 return null;
201 }
202 return (
203 running.find((session) => session.target.terminalId === DEFAULT_TERMINAL_ID) ??
204 running[0] ??
205 null
206 );
207}
208
209export function ThreadTerminalRouteScreen() {
210 const router = useRouter();
211 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
212 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
213 const params = useLocalSearchParams<{
214 environmentId?: string | string[];
215 threadId?: string | string[];
216 terminalId?: string | string[];
217 }>();
218 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
219 useThreadSelection();
220 const selectedThreadDetail = useSelectedThreadDetail();
221 const routeEnvironmentIdRaw = firstRouteParam(params.environmentId);
222 const routeThreadIdRaw = firstRouteParam(params.threadId);
223 const routeEnvironmentId = routeEnvironmentIdRaw
224 ? EnvironmentId.make(routeEnvironmentIdRaw)
225 : null;
226 const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null;
227 const requestedTerminalId = firstRouteParam(params.terminalId);
228 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;
229 const cachedFontSize = getCachedTerminalFontSize();
230 const cachedRouteGridSize =
231 routeEnvironmentId && routeThreadId
232 ? getCachedTerminalGridSize({
233 environmentId: routeEnvironmentId,
234 threadId: routeThreadId,
235 terminalId,
236 })
237 : null;
238 const knownSessions = useKnownTerminalSessions({
239 environmentId: selectedThread?.environmentId ?? null,
240 threadId: selectedThread?.id ?? null,
241 });
242 const [lastGridSize, setLastGridSize] = useState(
243 cachedRouteGridSize ?? {
244 cols: DEFAULT_TERMINAL_COLS,
245 rows: DEFAULT_TERMINAL_ROWS,
246 },
247 );
248 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
249 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);
250 const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);
251 const toolbarScrollGeometryRef = useRef({
252 contentWidth: 0,
253 offsetX: 0,
254 viewportWidth: 0,
255 });
256 const [toolbarScrollEdges, setToolbarScrollEdges] = useState({
257 showLeftFade: false,
258 showRightFade: false,
259 });
260 const hasOpenedRef = useRef(false);
261 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
262 const attachStreamLogCountRef = useRef(0);
263 const firstNonEmptyBufferLoggedRef = useRef(false);
264 const lastBufferReplayKeyRef = useRef<string | null>(null);
265 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
266 const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
267 cachedFontSize !== null,
268 );
269 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */
270 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);
271 const [pendingModifierState, setPendingModifierState] = useState<{
272 readonly terminalId: string;
273 readonly value: PendingModifier | null;
274 }>({
275 terminalId,
276 value: null,
277 });
278 const target = useTerminalSessionTarget({
279 environmentId: selectedThread?.environmentId ?? null,
280 threadId: selectedThread?.id ?? null,
281 terminalId,
282 });
283 const terminal = useTerminalSession(target);
284 const terminalKey = selectedThread
285 ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}`
286 : terminalId;
287 const bufferReplayKey = useMemo(
288 () => getTerminalBufferReplayKey({ terminalKey, fontSize }),
289 [fontSize, terminalKey],
290 );
291 if (lastBufferReplayKeyRef.current === null) {
292 lastBufferReplayKeyRef.current = bufferReplayKey;
293 }
294 const terminalSurfaceBuffer = getTerminalSurfaceReplayBuffer({
295 buffer: terminal.buffer,
296 replayKey: bufferReplayKey,
297 readyReplayKey: readyBufferReplayKey,
298 });
299 const isRunning = terminal.status === "running" || terminal.status === "starting";
300
301 useEffect(() => {
302 terminalDebugLog("surface:props", {
303 terminalKey,
304 atomBufferLen: terminal.buffer.length,
305 surfaceBufferLen: terminalSurfaceBuffer.length,
306 replayKey: bufferReplayKey,
307 readyReplayKey: readyBufferReplayKey,
308 status: terminal.status,
309 version: terminal.version,
310 });
311 }, [
312 bufferReplayKey,
313 readyBufferReplayKey,
314 terminal.buffer.length,
315 terminal.status,
316 terminal.version,
317 terminalKey,
318 terminalSurfaceBuffer.length,
319 ]);
320
321 useEffect(() => {
322 terminalDebugLog("session:status", {
323 terminalKey,
324 status: terminal.status,
325 error: terminal.error,
326 summary: terminal.summary?.cwd ?? null,
327 bufferLen: terminal.buffer.length,
328 version: terminal.version,
329 });
330 }, [
331 terminal.buffer.length,
332 terminal.error,
333 terminal.status,
334 terminal.summary?.cwd,
335 terminal.version,
336 terminalKey,
337 ]);
338
339 useEffect(() => {
340 if (terminal.buffer.length === 0 || firstNonEmptyBufferLoggedRef.current) {
341 return;
342 }
343 firstNonEmptyBufferLoggedRef.current = true;
344 terminalDebugLog("session:first-nonempty-buffer", {
345 terminalKey,
346 length: terminal.buffer.length,
347 preview: terminal.buffer.slice(0, 160),
348 });
349 }, [terminal.buffer, terminal.buffer.length, terminalKey]);
350 const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;
351 const hostPlatform = useMemo(
352 () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),
353 [selectedEnvironmentConnection?.environmentLabel],
354 );
355 const runningSession = useMemo(
356 () => pickRunningTerminalSessionForBootstrap(knownSessions),
357 [knownSessions],
358 );
359 const activeKnownSession = useMemo(
360 () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,
361 [knownSessions, terminalId],
362 );
363
364 const terminalAttachLaunchHintsRef = useRef({
365 terminalSummary: terminal.summary,
366 activeKnownSummary: activeKnownSession?.state.summary ?? null,
367 });
368 terminalAttachLaunchHintsRef.current = {
369 terminalSummary: terminal.summary,
370 activeKnownSummary: activeKnownSession?.state.summary ?? null,
371 };
372
373 const terminalStatusTone = useMemo(
374 () =>
375 getTerminalStatusTone({
376 status: terminal.status,
377 hasRunningSubprocess: terminal.hasRunningSubprocess,
378 }),
379 [terminal.hasRunningSubprocess, terminal.status],
380 );
381 const terminalTheme = getPierreTerminalTheme(appearanceScheme);
382 const pendingModifier =
383 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
384 const headerTitle = useMemo(() => {
385 const topLineParts = [
386 selectedEnvironmentConnection?.environmentLabel ?? null,
387 selectedThreadProject?.title ?? null,
388 ].filter((value): value is string => Boolean(value));
389
390 return {
391 topLine: topLineParts.join(" \u00b7 "),
392 bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "",
393 };
394 }, [
395 cwd,
396 selectedEnvironmentConnection?.environmentLabel,
397 selectedThreadProject?.title,
398 selectedThreadProject?.workspaceRoot,
399 ]);
400 const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => {
401 const modifierActions: ReadonlyArray<TerminalToolbarAction> =
402 hostPlatform === "mac"
403 ? [
404 { kind: "modifier", key: "cmd", label: "cmd", modifier: "meta" },
405 { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" },
406 ]
407 : [
408 { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" },
409 { kind: "modifier", key: "alt", label: "alt", modifier: "meta" },
410 ];
411
412 return [
413 { kind: "send", key: "esc", label: "esc", data: "\u001b" },
414 ...modifierActions,
415 { kind: "send", key: "tab", label: "tab", data: "\t" },
416 { kind: "clear", key: "clear", label: "clear" },
417 { kind: "send", key: "up", label: "↑", data: "\u001b[A" },
418 { kind: "send", key: "down", label: "↓", data: "\u001b[B" },
419 { kind: "send", key: "left", label: "←", data: "\u001b[D" },
420 { kind: "send", key: "right", label: "→", data: "\u001b[C" },
421 { kind: "send", key: "tilde", label: "~", data: "~" },
422 { kind: "send", key: "pipe", label: "|", data: "|" },
423 { kind: "send", key: "slash", label: "/", data: "/" },
424 { kind: "send", key: "dash", label: "-", data: "-" },
425 ];
426 }, [hostPlatform]);
427 const keyboardState = useKeyboardState((state) => ({
428 height: state.height,
429 isVisible: state.isVisible,
430 }));
431 const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed;
432 const terminalBottomInset =
433 (keyboardState.isVisible ? keyboardState.height : 0) +
434 (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0);
435
436 useEffect(() => {
437 const keyboardWillShow = KeyboardEvents.addListener("keyboardWillShow", () => {
438 setIsAccessoryDismissed(false);
439 });
440 const keyboardWillHide = KeyboardEvents.addListener("keyboardWillHide", () => {
441 setIsAccessoryDismissed(true);
442 });
443
444 return () => {
445 keyboardWillShow.remove();
446 keyboardWillHide.remove();
447 };
448 }, []);
449
450 const terminalMenuSessions = useMemo<ReadonlyArray<TerminalMenuSession>>(
451 () =>
452 buildTerminalMenuSessions({
453 knownSessions,
454 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
455 currentSession: {
456 terminalId,
457 cwd: cwd ?? null,
458 status: terminal.status,
459 hasRunningSubprocess: terminal.hasRunningSubprocess,
460 displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary),
461 updatedAt: terminal.updatedAt,
462 },
463 }),
464 [
465 cwd,
466 knownSessions,
467 selectedThreadProject?.workspaceRoot,
468 terminal.hasRunningSubprocess,
469 terminal.summary,
470 terminal.status,
471 terminal.updatedAt,
472 terminalId,
473 ],
474 );
475
476 const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => {
477 const n = ++attachStreamLogCountRef.current;
478 if (event.type === "output" && n > 32 && n % 64 !== 0) {
479 return;
480 }
481 if (event.type === "snapshot") {
482 terminalDebugLog("attach:stream", {
483 n,
484 type: event.type,
485 status: event.snapshot.status,
486 historyLen: event.snapshot.history.length,
487 cwd: event.snapshot.cwd,
488 });
489 return;
490 }
491 if (event.type === "output") {
492 terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length });
493 return;
494 }
495 terminalDebugLog("attach:stream", { n, type: event.type });
496 }, []);
497
498 const attachTerminal = useCallback(() => {
499 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
500 terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" });
501 return null;
502 }
503
504 const client = getEnvironmentClient(selectedThread.environmentId);
505 if (!client) {
506 terminalDebugLog("attach:abort", {
507 reason: "no-environment-client",
508 environmentId: selectedThread.environmentId,
509 });
510 return null;
511 }
512
513 const pendingLaunchTarget = {
514 environmentId: selectedThread.environmentId,
515 threadId: selectedThread.id,
516 terminalId,
517 };
518 const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget);
519 let initialInputSent = false;
520
521 try {
522 const launchLocation = pendingLaunch
523 ? {
524 cwd: pendingLaunch.cwd,
525 worktreePath: pendingLaunch.worktreePath,
526 }
527 : resolveTerminalOpenLocation({
528 terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary,
529 activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary,
530 workspaceRoot: selectedThreadProject.workspaceRoot,
531 threadShellWorktreePath: selectedThread.worktreePath ?? null,
532 threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
533 });
534
535 terminalDebugLog("attach:start", {
536 terminalId,
537 threadId: selectedThread.id,
538 cols: lastGridSize.cols,
539 rows: lastGridSize.rows,
540 cwd: launchLocation.cwd,
541 worktreePath: launchLocation.worktreePath,
542 });
543
544 return attachTerminalSession({
545 environmentId: selectedThread.environmentId,
546 client,
547 terminal: {
548 threadId: selectedThread.id,
549 terminalId,
550 cwd: launchLocation.cwd,
551 worktreePath: launchLocation.worktreePath,
552 cols: lastGridSize.cols,
553 rows: lastGridSize.rows,
554 env: pendingLaunch?.env,
555 ...(pendingLaunch ? { restartIfNotRunning: true } : {}),
556 },
557 onEvent: logAttachStreamEvent,
558 onSnapshot: () => {
559 if (!pendingLaunch?.initialInput || initialInputSent) {
560 return;
561 }
562
563 initialInputSent = true;
564 void client.terminal.write({
565 threadId: selectedThread.id,
566 terminalId,
567 data: pendingLaunch.initialInput,
568 });
569 },
570 });
571 } catch (error) {
572 terminalDebugLog("attach:error", {
573 message: error instanceof Error ? error.message : String(error),
574 });
575 if (pendingLaunch) {
576 stagePendingTerminalLaunch({
577 target: pendingLaunchTarget,
578 launch: pendingLaunch,
579 });
580 }
581
582 throw error;
583 }
584 }, [
585 lastGridSize.cols,
586 lastGridSize.rows,
587 logAttachStreamEvent,
588 selectedThreadDetail?.worktreePath,
589 selectedThread,
590 selectedThreadProject?.workspaceRoot,
591 terminalId,
592 ]);
593
594 const attachTerminalRef = useRef(attachTerminal);
595 attachTerminalRef.current = attachTerminal;
596 const selectedThreadRef = useRef(selectedThread);
597 selectedThreadRef.current = selectedThread;
598 const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject);
599 selectedThreadProjectBootstrapRef.current = selectedThreadProject;
600 const runningSessionRef = useRef(runningSession);
601 runningSessionRef.current = runningSession;
602 const terminalBootstrapRef = useRef({
603 status: terminal.status,
604 bufferLen: terminal.buffer.length,
605 });
606 terminalBootstrapRef.current = {
607 status: terminal.status,
608 bufferLen: terminal.buffer.length,
609 };
610
611 useEffect(() => {
612 hasOpenedRef.current = false;
613 attachStreamLogCountRef.current = 0;
614 firstNonEmptyBufferLoggedRef.current = false;
615 }, [terminalKey]);
616
617 const clearBufferReplayTimer = useCallback(() => {
618 if (bufferReplayTimerRef.current !== null) {
619 clearTimeout(bufferReplayTimerRef.current);
620 bufferReplayTimerRef.current = null;
621 }
622 }, []);
623
624 const scheduleBufferReplayReady = useCallback(() => {
625 clearBufferReplayTimer();
626 const replayKey = bufferReplayKey;
627 terminalDebugLog("replay:schedule-ready", {
628 replayKey,
629 delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
630 });
631 bufferReplayTimerRef.current = setTimeout(() => {
632 bufferReplayTimerRef.current = null;
633 setReadyBufferReplayKey(replayKey);
634 terminalDebugLog("replay:ready", { replayKey });
635 }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS);
636 }, [bufferReplayKey, clearBufferReplayTimer]);
637
638 useEffect(() => {
639 if (lastBufferReplayKeyRef.current === bufferReplayKey) {
640 return;
641 }
642
643 lastBufferReplayKeyRef.current = bufferReplayKey;
644 clearBufferReplayTimer();
645 setReadyBufferReplayKey(null);
646 }, [bufferReplayKey, clearBufferReplayTimer]);
647
648 useEffect(() => clearBufferReplayTimer, [clearBufferReplayTimer]);
649
650 useEffect(() => {
651 if (!routeEnvironmentId || !routeThreadId) {
652 setLastGridSize({
653 cols: DEFAULT_TERMINAL_COLS,
654 rows: DEFAULT_TERMINAL_ROWS,
655 });
656 return;
657 }
658
659 setLastGridSize(
660 getCachedTerminalGridSize({
661 environmentId: routeEnvironmentId,
662 threadId: routeThreadId,
663 terminalId,
664 }) ?? {
665 cols: DEFAULT_TERMINAL_COLS,
666 rows: DEFAULT_TERMINAL_ROWS,
667 },
668 );
669 setHasMeasuredSurface(true);
670 }, [routeEnvironmentId, routeThreadId, terminalId]);
671
672 useEffect(() => {
673 let cancelled = false;
674
675 void loadPreferences()
676 .then((preferences) => {
677 if (cancelled) {
678 return;
679 }
680
681 setFontSize(cacheTerminalFontSize(preferences.terminalFontSize));
682 setHasResolvedFontPreference(true);
683 })
684 .catch(() => {
685 if (cancelled) {
686 return;
687 }
688
689 setHasResolvedFontPreference(true);
690 });
691
692 return () => {
693 cancelled = true;
694 };
695 }, []);
696
697 useEffect(() => {
698 if (!hasResolvedFontPreference) {
699 return;
700 }
701
702 cacheTerminalFontSize(fontSize);
703 void savePreferencesPatch({
704 terminalFontSize: normalizeTerminalFontSize(fontSize),
705 });
706 }, [fontSize, hasResolvedFontPreference]);
707
708 // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change.
709 // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when
710 // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup
711 // → detach immediately after the first snapshot.
712 useEffect(() => {
713 if (!hasResolvedFontPreference || !hasMeasuredSurface) {
714 return;
715 }
716
717 const thread = selectedThreadRef.current;
718 const project = selectedThreadProjectBootstrapRef.current;
719 const running = runningSessionRef.current;
720 const termSnap = terminalBootstrapRef.current;
721
722 const bootstrapAction = resolveTerminalRouteBootstrap({
723 hasThread: thread !== null,
724 hasWorkspaceRoot: Boolean(project?.workspaceRoot),
725 hasOpened: hasOpenedRef.current,
726 requestedTerminalId,
727 currentTerminalId: terminalId,
728 runningTerminalId: running?.target.terminalId ?? null,
729 currentTerminalStatus: termSnap.status,
730 // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`;
731 // treating summary as "hydrated" skipped attach while status was running → empty surface.
732 hasCurrentTerminalHydration: termSnap.bufferLen > 0,
733 });
734 if (bootstrapAction.kind !== "idle") {
735 terminalDebugLog("bootstrap:action", {
736 kind: bootstrapAction.kind,
737 hasOpenedBefore: hasOpenedRef.current,
738 hasHydration: termSnap.bufferLen > 0,
739 terminalStatus: termSnap.status,
740 bufLen: termSnap.bufferLen,
741 });
742 }
743 if (bootstrapAction.kind === "idle" || !thread) {
744 return;
745 }
746
747 if (bootstrapAction.kind === "redirect") {
748 router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId));
749 return;
750 }
751
752 hasOpenedRef.current = true;
753 try {
754 const detach = attachTerminalRef.current();
755 terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) });
756 if (!detach) {
757 hasOpenedRef.current = false;
758 return;
759 }
760 return () => {
761 detach();
762 hasOpenedRef.current = false;
763 terminalDebugLog("bootstrap:unsubscribe");
764 };
765 } catch (error) {
766 hasOpenedRef.current = false;
767 terminalDebugLog("bootstrap:attach-threw", {
768 message: error instanceof Error ? error.message : String(error),
769 });
770 return;
771 }
772 }, [
773 hasMeasuredSurface,
774 hasResolvedFontPreference,
775 requestedTerminalId,
776 router,
777 selectedThread?.environmentId,
778 selectedThread?.id,
779 selectedThreadProject?.workspaceRoot,
780 terminalId,
781 ]);
782
783 const writeInput = useCallback(
784 (data: string) => {
785 if (!selectedThread || !isRunning) {
786 return;
787 }
788
789 const client = getEnvironmentClient(selectedThread.environmentId);
790 if (!client) {
791 return;
792 }
793
794 void client.terminal.write({
795 threadId: selectedThread.id,
796 terminalId,
797 data,
798 });
799 },
800 [isRunning, selectedThread, terminalId],
801 );
802
803 const handleInput = useCallback(
804 (data: string) => {
805 if (data.length === 0) {
806 return;
807 }
808
809 if (pendingModifier === "ctrl") {
810 setPendingModifierState({ terminalId, value: null });
811 writeInput(applyCtrlModifier(data));
812 } else if (pendingModifier === "meta") {
813 setPendingModifierState({ terminalId, value: null });
814 writeInput(`\u001b${data}`);
815 } else {
816 writeInput(data);
817 }
818 },
819 [pendingModifier, terminalId, writeInput],
820 );
821
822 const handleResize = useCallback(
823 (size: { readonly cols: number; readonly rows: number }) => {
824 terminalDebugLog("native:onResize", {
825 cols: size.cols,
826 rows: size.rows,
827 terminalKey,
828 });
829 setHasMeasuredSurface(true);
830 if (readyBufferReplayKey !== bufferReplayKey) {
831 scheduleBufferReplayReady();
832 }
833 if (routeEnvironmentId && routeThreadId) {
834 cacheTerminalGridSize(
835 {
836 environmentId: routeEnvironmentId,
837 threadId: routeThreadId,
838 terminalId,
839 },
840 size,
841 );
842 }
843 if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) {
844 return;
845 }
846
847 setLastGridSize(size);
848 if (!selectedThread || !isRunning) {
849 return;
850 }
851
852 const client = getEnvironmentClient(selectedThread.environmentId);
853 if (!client) {
854 return;
855 }
856
857 void client.terminal.resize({
858 threadId: selectedThread.id,
859 terminalId,
860 cols: size.cols,
861 rows: size.rows,
862 });
863 },
864 [
865 isRunning,
866 lastGridSize.cols,
867 lastGridSize.rows,
868 bufferReplayKey,
869 readyBufferReplayKey,
870 routeEnvironmentId,
871 routeThreadId,
872 scheduleBufferReplayReady,
873 selectedThread,
874 terminalId,
875 terminalKey,
876 ],
877 );
878
879 const handleSelectTerminal = useCallback(
880 (nextTerminalId: string) => {
881 if (!selectedThread || nextTerminalId === terminalId) {
882 return;
883 }
884
885 router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId));
886 },
887 [router, selectedThread, terminalId],
888 );
889
890 const handleOpenNewTerminal = useCallback(() => {
891 if (!selectedThread) {
892 return;
893 }
894
895 router.replace(
896 buildThreadTerminalNavigation(
897 selectedThread,
898 nextOpenTerminalId({
899 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
900 activeRouteTerminalId: terminalId,
901 }),
902 ),
903 );
904 }, [router, selectedThread, terminalId, terminalMenuSessions]);
905
906 const adjustFontSize = useCallback((delta: number) => {
907 setTimeout(() => {
908 setFontSize((current) => cacheTerminalFontSize(current + delta));
909 }, 0);
910 }, []);
911
912 const handleDecreaseFontSize = useCallback(() => {
913 adjustFontSize(-TERMINAL_FONT_SIZE_STEP);
914 }, [adjustFontSize]);
915
916 const handleIncreaseFontSize = useCallback(() => {
917 adjustFontSize(TERMINAL_FONT_SIZE_STEP);
918 }, [adjustFontSize]);
919
920 const handleClearTerminal = useCallback(() => {
921 if (!selectedThread) {
922 return;
923 }
924
925 const client = getEnvironmentClient(selectedThread.environmentId);
926 if (!client) {
927 return;
928 }
929
930 setPendingModifierState({ terminalId, value: null });
931 void client.terminal.clear({
932 threadId: selectedThread.id,
933 terminalId,
934 });
935 }, [selectedThread, terminalId]);
936
937 const handleToolbarActionPress = useCallback(
938 (action: TerminalToolbarAction) => {
939 if (action.kind === "modifier") {
940 setPendingModifierState((current) => ({
941 terminalId,
942 value:
943 (current.terminalId === terminalId ? current.value : null) === action.modifier
944 ? null
945 : action.modifier,
946 }));
947 return;
948 }
949
950 if (action.kind === "clear") {
951 handleClearTerminal();
952 return;
953 }
954
955 setPendingModifierState({ terminalId, value: null });
956 if (pendingModifier === "ctrl") {
957 writeInput(applyCtrlModifier(action.data));
958 } else if (pendingModifier === "meta") {
959 writeInput(`\u001b${action.data}`);
960 } else {
961 writeInput(action.data);
962 }
963 },
964 [handleClearTerminal, pendingModifier, terminalId, writeInput],
965 );
966
967 const updateToolbarScrollEdges = useCallback(
968 (patch: Partial<(typeof toolbarScrollGeometryRef)["current"]>) => {
969 const nextGeometry = { ...toolbarScrollGeometryRef.current, ...patch };
970 toolbarScrollGeometryRef.current = nextGeometry;
971 const nextEdges = {
972 showLeftFade: nextGeometry.offsetX > 1,
973 showRightFade:
974 nextGeometry.offsetX + nextGeometry.viewportWidth < nextGeometry.contentWidth - 1,
975 };
976
977 setToolbarScrollEdges((current) =>
978 current.showLeftFade === nextEdges.showLeftFade &&
979 current.showRightFade === nextEdges.showRightFade
980 ? current
981 : nextEdges,
982 );
983 },
984 [],
985 );
986
987 const handleToolbarLayout = useCallback(
988 (event: LayoutChangeEvent) => {
989 updateToolbarScrollEdges({ viewportWidth: event.nativeEvent.layout.width });
990 },
991 [updateToolbarScrollEdges],
992 );
993
994 const handleToolbarContentSizeChange = useCallback(
995 (contentWidth: number) => {
996 updateToolbarScrollEdges({ contentWidth });
997 },
998 [updateToolbarScrollEdges],
999 );
1000
1001 const handleToolbarScroll = useCallback(
1002 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
1003 updateToolbarScrollEdges({ offsetX: event.nativeEvent.contentOffset.x });
1004 },
1005 [updateToolbarScrollEdges],
1006 );
1007
1008 const handleDismissKeyboard = useCallback(() => {
1009 setIsAccessoryDismissed(true);
1010 void KeyboardController.dismiss();
1011 }, []);
1012
1013 const handleShowKeyboard = useCallback(() => {
1014 setKeyboardFocusRequest((current) => current + 1);
1015 }, []);
1016
1017 if (!selectedThread) {
1018 if (isLoadingSavedConnection) {
1019 return <LoadingScreen message="Opening terminal…" />;
1020 }
1021
1022 return (
1023 <View className="flex-1 bg-screen">
1024 <EmptyState
1025 title="Thread unavailable"
1026 detail="This terminal route needs an active thread and workspace."
1027 />
1028 </View>
1029 );
1030 }
1031
1032 if (!selectedThreadProject?.workspaceRoot) {
1033 return (
1034 <View className="flex-1 bg-screen">
1035 <EmptyState
1036 title="Terminal unavailable"
1037 detail="This thread does not have a workspace root yet, so there is nowhere to open a shell."
1038 />
1039 </View>
1040 );
1041 }
1042
1043 return (
1044 <>
1045 <Stack.Screen
1046 options={{
1047 headerShown: true,
1048 headerBackButtonDisplayMode: "minimal",
1049 headerBackTitle: "",
1050 headerShadowVisible: false,
1051 headerStyle: { backgroundColor: terminalTheme.background },
1052 headerTintColor: terminalTheme.foreground,
1053 headerTitleAlign: "center",
1054 title: "",
1055 headerTitle: () => (
1056 <View
1057 style={{
1058 alignItems: "center",
1059 gap: 1,
1060 maxWidth: 240,
1061 }}
1062 >
1063 <RNText
1064 numberOfLines={1}
1065 style={{
1066 color: terminalTheme.foreground,
1067 fontFamily: "DMSans_700Bold",
1068 fontSize: 13,
1069 lineHeight: 16,
1070 }}
1071 >
1072 {headerTitle.topLine}
1073 </RNText>
1074 <RNText
1075 ellipsizeMode="middle"
1076 numberOfLines={1}
1077 style={{
1078 color: terminalTheme.mutedForeground,
1079 fontFamily: "Menlo",
1080 fontSize: 11,
1081 lineHeight: 14,
1082 }}
1083 >
1084 {headerTitle.bottomLine}
1085 </RNText>
1086 </View>
1087 ),
1088 }}
1089 />
1090
1091 <Stack.Toolbar placement="right">
1092 <Stack.Toolbar.Menu
1093 icon="circle.fill"
1094 style={{
1095 color: terminalStatusTone.textColor,
1096 fontFamily: "DMSans_700Bold",
1097 fontSize: 12,
1098 fontWeight: "700",
1099 }}
1100 tintColor={terminalStatusTone.tintColor}
1101 title="Terminal options"
1102 >
1103 <Stack.Toolbar.Label>
1104 {getTerminalStatusLabel({
1105 status: terminal.status,
1106 hasRunningSubprocess: terminal.hasRunningSubprocess,
1107 })}
1108 </Stack.Toolbar.Label>
1109 <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size">
1110 <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label>
1111 <Stack.Toolbar.MenuAction
1112 disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
1113 discoverabilityLabel="Decrease terminal text size"
1114 onPress={handleDecreaseFontSize}
1115 >
1116 <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>
1117 </Stack.Toolbar.MenuAction>
1118 <Stack.Toolbar.MenuAction
1119 disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
1120 discoverabilityLabel="Increase terminal text size"
1121 onPress={handleIncreaseFontSize}
1122 >
1123 <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>
1124 </Stack.Toolbar.MenuAction>
1125 </Stack.Toolbar.Menu>
1126 {terminalMenuSessions.map((session) => (
1127 <Stack.Toolbar.MenuAction
1128 key={session.terminalId}
1129 icon={session.terminalId === terminalId ? "checkmark" : "terminal"}
1130 onPress={() => handleSelectTerminal(session.terminalId)}
1131 subtitle={[getTerminalStatusLabel({ status: session.status }), basename(session.cwd)]
1132 .filter(Boolean)
1133 .join(" · ")}
1134 >
1135 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
1136 </Stack.Toolbar.MenuAction>
1137 ))}
1138 <Stack.Toolbar.MenuAction
1139 icon="plus"
1140 onPress={handleOpenNewTerminal}
1141 subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`}
1142 >
1143 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
1144 </Stack.Toolbar.MenuAction>
1145 </Stack.Toolbar.Menu>
1146 </Stack.Toolbar>
1147
1148 <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>
1149 <View style={{ flex: 1, paddingBottom: terminalBottomInset }}>
1150 <TerminalSurface
1151 buffer={terminalSurfaceBuffer}
1152 fontSize={fontSize}
1153 isRunning={isRunning}
1154 keyboardFocusRequest={keyboardFocusRequest}
1155 onInput={handleInput}
1156 onResize={handleResize}
1157 style={{ flex: 1 }}
1158 terminalKey={terminalKey}
1159 />
1160 </View>
1161
1162 {isAccessoryVisible ? (
1163 <KeyboardStickyView
1164 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1165 offset={{ closed: 0, opened: 0 }}
1166 >
1167 <View
1168 style={{
1169 backgroundColor: terminalTheme.background,
1170 borderTopColor: terminalTheme.border,
1171 borderTopWidth: 1,
1172 minHeight: TERMINAL_ACCESSORY_HEIGHT,
1173 paddingBottom: 4,
1174 paddingHorizontal: 8,
1175 paddingTop: 4,
1176 }}
1177 >
1178 <View style={{ alignItems: "center", flexDirection: "row", gap: 6 }}>
1179 <View style={{ flex: 1, position: "relative" }}>
1180 <ScrollView
1181 horizontal
1182 contentContainerStyle={{ alignItems: "center", gap: 6, paddingRight: 2 }}
1183 onContentSizeChange={handleToolbarContentSizeChange}
1184 onLayout={handleToolbarLayout}
1185 onScroll={handleToolbarScroll}
1186 scrollEventThrottle={16}
1187 showsHorizontalScrollIndicator={false}
1188 >
1189 {terminalToolbarActions.map((action) => {
1190 const active =
1191 action.kind === "modifier" && pendingModifier === action.modifier;
1192
1193 return (
1194 <Pressable
1195 key={action.key}
1196 onPress={() => handleToolbarActionPress(action)}
1197 style={({ pressed }) => ({
1198 alignItems: "center",
1199 backgroundColor: active
1200 ? withAlpha(
1201 terminalTheme.palette[10] ?? terminalTheme.foreground,
1202 "2e",
1203 )
1204 : pressed
1205 ? withAlpha(terminalTheme.foreground, "1f")
1206 : withAlpha(terminalTheme.foreground, "12"),
1207 borderColor: active
1208 ? withAlpha(
1209 terminalTheme.palette[10] ?? terminalTheme.foreground,
1210 "52",
1211 )
1212 : terminalTheme.border,
1213 borderRadius: 12,
1214 borderWidth: 1,
1215 justifyContent: "center",
1216 minWidth: action.label.length > 1 ? 46 : 38,
1217 paddingHorizontal: 11,
1218 paddingVertical: 8,
1219 })}
1220 >
1221 <RNText
1222 style={{
1223 color: active
1224 ? (terminalTheme.palette[10] ?? terminalTheme.foreground)
1225 : terminalTheme.foreground,
1226 fontFamily: "DMSans_700Bold",
1227 fontSize: 12,
1228 fontWeight: "700",
1229 textTransform:
1230 action.kind === "modifier" || action.kind === "clear"
1231 ? "uppercase"
1232 : "none",
1233 }}
1234 >
1235 {action.label}
1236 </RNText>
1237 </Pressable>
1238 );
1239 })}
1240 </ScrollView>
1241 {toolbarScrollEdges.showLeftFade ? (
1242 <View
1243 pointerEvents="none"
1244 style={{
1245 bottom: 0,
1246 experimental_backgroundImage: `linear-gradient(to right, ${terminalTheme.background} 0%, ${withAlpha(terminalTheme.background, "00")} 100%)`,
1247 left: 0,
1248 position: "absolute",
1249 top: 0,
1250 width: TERMINAL_ACCESSORY_FADE_WIDTH,
1251 }}
1252 />
1253 ) : null}
1254 {toolbarScrollEdges.showRightFade ? (
1255 <View
1256 pointerEvents="none"
1257 style={{
1258 bottom: 0,
1259 experimental_backgroundImage: `linear-gradient(to right, ${withAlpha(terminalTheme.background, "00")} 0%, ${terminalTheme.background} 100%)`,
1260 position: "absolute",
1261 right: 0,
1262 top: 0,
1263 width: TERMINAL_ACCESSORY_FADE_WIDTH,
1264 }}
1265 />
1266 ) : null}
1267 </View>
1268 <Pressable
1269 accessibilityLabel="Dismiss keyboard"
1270 accessibilityRole="button"
1271 onPress={handleDismissKeyboard}
1272 style={({ pressed }) => ({
1273 alignItems: "center",
1274 backgroundColor: pressed
1275 ? withAlpha(terminalTheme.foreground, "1f")
1276 : withAlpha(terminalTheme.foreground, "12"),
1277 borderColor: terminalTheme.border,
1278 borderRadius: 12,
1279 borderWidth: 1,
1280 justifyContent: "center",
1281 minWidth: 38,
1282 paddingHorizontal: 11,
1283 paddingVertical: 8,
1284 })}
1285 >
1286 <SymbolView
1287 name={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }}
1288 size={14}
1289 tintColor={terminalTheme.foreground}
1290 type="monochrome"
1291 />
1292 </Pressable>
1293 </View>
1294 </View>
1295 </KeyboardStickyView>
1296 ) : !keyboardState.isVisible ? (
1297 <Pressable
1298 accessibilityLabel="Show keyboard"
1299 accessibilityRole="button"
1300 onPress={handleShowKeyboard}
1301 style={({ pressed }) => ({
1302 bottom: 16,
1303 borderRadius: 28,
1304 opacity: pressed ? 0.72 : 1,
1305 position: "absolute",
1306 right: 16,
1307 })}
1308 >
1309 <GlassSurface
1310 chrome="none"
1311 glassEffectStyle="regular"
1312 tintColor="transparent"
1313 style={{
1314 alignItems: "center",
1315 borderRadius: 24,
1316 height: 48,
1317 justifyContent: "center",
1318 width: 48,
1319 }}
1320 pointerEvents="none"
1321 >
1322 <SymbolView
1323 name={{ ios: "keyboard", android: "keyboard" }}
1324 size={20}
1325 tintColor={terminalTheme.foreground}
1326 type="monochrome"
1327 />
1328 </GlassSurface>
1329 </Pressable>
1330 ) : null}
1331 </View>
1332 </>
1333 );
1334}
apps/mobile/src/features/terminal/nativeTerminalModule.test.ts created+52
...@@ -0,0 +1,52 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3const expoMocks = vi.hoisted(() => ({
4 requireNativeView: vi.fn(),
5}));
6const nativeView = () => null;
7const originalExpo = globalThis.expo;
8
9function setExpoViewConfigAvailable() {
10 globalThis.expo = {
11 getViewConfig: vi.fn().mockReturnValue({ validAttributes: {}, directEventTypes: {} }),
12 } as unknown as typeof globalThis.expo;
13}
14
15vi.mock("expo", () => ({
16 requireNativeView: expoMocks.requireNativeView,
17}));
18
19describe("resolveNativeTerminalSurfaceView", () => {
20 beforeEach(() => {
21 vi.clearAllMocks();
22 vi.resetModules();
23 globalThis.expo = undefined as unknown as typeof globalThis.expo;
24 });
25
26 afterEach(() => {
27 globalThis.expo = originalExpo;
28 });
29
30 it("returns null when the native terminal view config is unavailable", async () => {
31 const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule");
32 expect(resolveNativeTerminalSurfaceView()).toBeNull();
33 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();
34 });
35
36 it("returns the native terminal view when the view config is installed", async () => {
37 setExpoViewConfigAvailable();
38 expoMocks.requireNativeView.mockReturnValue(nativeView);
39 const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule");
40 expect(resolveNativeTerminalSurfaceView()).toBe(nativeView);
41 expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3TerminalSurface");
42 });
43
44 it("returns null when the view manager cannot be required", async () => {
45 setExpoViewConfigAvailable();
46 expoMocks.requireNativeView.mockImplementation(() => {
47 throw new Error("boom");
48 });
49 const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule");
50 expect(resolveNativeTerminalSurfaceView()).toBeNull();
51 });
52});
apps/mobile/src/features/terminal/nativeTerminalModule.ts created+65
...@@ -0,0 +1,65 @@
1import type { ComponentType } from "react";
2import type { NativeSyntheticEvent, ViewProps } from "react-native";
3import { requireNativeView } from "expo";
4
5const NATIVE_TERMINAL_MODULE_NAME = "T3TerminalSurface";
6
7interface ExpoGlobalWithViewConfig {
8 readonly expo?: {
9 getViewConfig?: (moduleName: string, viewName?: string) => unknown;
10 };
11}
12
13interface TerminalInputEvent {
14 readonly data: string;
15}
16
17interface TerminalResizeEvent {
18 readonly cols: number;
19 readonly rows: number;
20}
21
22export interface NativeTerminalSurfaceProps extends ViewProps {
23 readonly appearanceScheme?: "light" | "dark";
24 readonly themeConfig?: string;
25 readonly backgroundColor?: string;
26 readonly foregroundColor?: string;
27 readonly mutedForegroundColor?: string;
28 readonly terminalKey: string;
29 readonly initialBuffer: string;
30 readonly fontSize: number;
31 readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void;
32 readonly onResize?: (event: NativeSyntheticEvent<TerminalResizeEvent>) => void;
33}
34
35let cachedNativeTerminalSurfaceView: ComponentType<NativeTerminalSurfaceProps> | undefined;
36
37function getExpoViewConfig(moduleName: string) {
38 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
39 moduleName,
40 );
41}
42
43export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminalSurfaceProps> | null {
44 if (cachedNativeTerminalSurfaceView) {
45 return cachedNativeTerminalSurfaceView;
46 }
47
48 if (getExpoViewConfig(NATIVE_TERMINAL_MODULE_NAME) == null) {
49 return null;
50 }
51
52 try {
53 cachedNativeTerminalSurfaceView = requireNativeView<NativeTerminalSurfaceProps>(
54 NATIVE_TERMINAL_MODULE_NAME,
55 );
56 } catch {
57 return null;
58 }
59
60 return cachedNativeTerminalSurfaceView ?? null;
61}
62
63export function hasNativeTerminalSurface() {
64 return resolveNativeTerminalSurfaceView() !== null;
65}
apps/mobile/src/features/terminal/terminalBufferReplay.test.ts created+43
...@@ -0,0 +1,43 @@
1import { describe, expect, it } from "vitest";
2
3import { getTerminalBufferReplayKey, getTerminalSurfaceReplayBuffer } from "./terminalBufferReplay";
4
5describe("terminalBufferReplay", () => {
6 it("keys replay readiness by terminal identity and font metrics", () => {
7 expect(
8 getTerminalBufferReplayKey({
9 terminalKey: "env-1:thread-1:default",
10 fontSize: 10,
11 }),
12 ).toBe("env-1:thread-1:default:10");
13 });
14
15 it("shows terminal history while replay key is unset (initial mount / after key change)", () => {
16 const replayKey = getTerminalBufferReplayKey({
17 terminalKey: "env-1:thread-1:default",
18 fontSize: 10,
19 });
20
21 expect(
22 getTerminalSurfaceReplayBuffer({
23 buffer: "fastfetch output",
24 replayKey,
25 readyReplayKey: null,
26 }),
27 ).toBe("fastfetch output");
28 expect(
29 getTerminalSurfaceReplayBuffer({
30 buffer: "fastfetch output",
31 replayKey,
32 readyReplayKey: "env-1:thread-1:default:11",
33 }),
34 ).toBe("");
35 expect(
36 getTerminalSurfaceReplayBuffer({
37 buffer: "fastfetch output",
38 replayKey,
39 readyReplayKey: replayKey,
40 }),
41 ).toBe("fastfetch output");
42 });
43});
apps/mobile/src/features/terminal/terminalBufferReplay.ts created+29
...@@ -0,0 +1,29 @@
1import { terminalDebugLog } from "./terminalDebugLog";
2
3export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180;
4
5export function getTerminalBufferReplayKey(input: {
6 readonly terminalKey: string;
7 readonly fontSize: number;
8}): string {
9 return `${input.terminalKey}:${input.fontSize}`;
10}
11
12export function getTerminalSurfaceReplayBuffer(input: {
13 readonly buffer: string;
14 readonly replayKey: string;
15 readonly readyReplayKey: string | null;
16}): string {
17 // Pass live buffer whenever ready key is unset or matches. Only return "" when ready key is
18 // stale vs current replay key (e.g. mid font-size transition).
19 if (input.readyReplayKey !== null && input.readyReplayKey !== input.replayKey) {
20 terminalDebugLog("replay:stale-key-hiding-buffer", {
21 replayKey: input.replayKey,
22 readyReplayKey: input.readyReplayKey,
23 bufferLen: input.buffer.length,
24 });
25 return "";
26 }
27
28 return input.buffer;
29}
apps/mobile/src/features/terminal/terminalDebugLog.ts created+24
...@@ -0,0 +1,24 @@
1/**
2 * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`.
3 *
4 * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS
5 * debugger / Metro console to trace release/TestFlight builds.
6 */
7export function isTerminalDebugEnabled(): boolean {
8 return (
9 (typeof __DEV__ !== "undefined" && __DEV__) ||
10 (typeof globalThis !== "undefined" &&
11 (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true)
12 );
13}
14
15export function terminalDebugLog(message: string, data?: Record<string, unknown>): void {
16 if (!isTerminalDebugEnabled()) {
17 return;
18 }
19 if (data !== undefined) {
20 console.log(`[t3-terminal] ${message}`, data);
21 } else {
22 console.log(`[t3-terminal] ${message}`);
23 }
24}
apps/mobile/src/features/terminal/terminalLaunchContext.test.ts created+130
...@@ -0,0 +1,130 @@
1import { describe, expect, it } from "vitest";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3
4import {
5 peekPendingTerminalLaunch,
6 resolvePreferredThreadWorktreePath,
7 resolveTerminalOpenLocation,
8 stagePendingTerminalLaunch,
9 takePendingTerminalLaunch,
10} from "./terminalLaunchContext";
11
12describe("resolvePreferredThreadWorktreePath", () => {
13 it("prefers thread detail worktree paths over thread shell paths", () => {
14 expect(
15 resolvePreferredThreadWorktreePath({
16 threadShellWorktreePath: "/repo/root",
17 threadDetailWorktreePath: "/repo/worktrees/feature",
18 }),
19 ).toBe("/repo/worktrees/feature");
20 });
21
22 it("falls back to the thread shell worktree path when detail is unavailable", () => {
23 expect(
24 resolvePreferredThreadWorktreePath({
25 threadShellWorktreePath: "/repo/worktrees/feature",
26 threadDetailWorktreePath: null,
27 }),
28 ).toBe("/repo/worktrees/feature");
29 });
30});
31
32describe("resolveTerminalOpenLocation", () => {
33 it("uses the thread detail worktree path before the workspace root for a fresh mobile open", () => {
34 expect(
35 resolveTerminalOpenLocation({
36 terminalLocation: null,
37 activeSessionLocation: null,
38 workspaceRoot: "/repo/root",
39 threadShellWorktreePath: null,
40 threadDetailWorktreePath: "/repo/worktrees/feature",
41 }),
42 ).toEqual({
43 cwd: "/repo/worktrees/feature",
44 worktreePath: "/repo/worktrees/feature",
45 });
46 });
47
48 it("preserves the running terminal snapshot cwd when attaching to an existing session", () => {
49 expect(
50 resolveTerminalOpenLocation({
51 terminalLocation: null,
52 activeSessionLocation: {
53 cwd: "/repo/worktrees/feature",
54 worktreePath: "/repo/worktrees/feature",
55 },
56 workspaceRoot: "/repo/root",
57 threadShellWorktreePath: null,
58 threadDetailWorktreePath: "/repo/worktrees/other",
59 }),
60 ).toEqual({
61 cwd: "/repo/worktrees/feature",
62 worktreePath: "/repo/worktrees/feature",
63 });
64 });
65});
66
67describe("pending terminal launches", () => {
68 it("stages and consumes launch details for a specific terminal target", () => {
69 const target = {
70 environmentId: EnvironmentId.make("env-1"),
71 threadId: ThreadId.make("thread-1"),
72 terminalId: "term-2",
73 };
74
75 stagePendingTerminalLaunch({
76 target,
77 launch: {
78 cwd: "/repo/worktrees/feature",
79 worktreePath: "/repo/worktrees/feature",
80 env: { FOO: "bar" },
81 initialInput: "pnpm dev\r",
82 },
83 });
84
85 expect(peekPendingTerminalLaunch(target)).toEqual({
86 cwd: "/repo/worktrees/feature",
87 worktreePath: "/repo/worktrees/feature",
88 env: { FOO: "bar" },
89 initialInput: "pnpm dev\r",
90 });
91 expect(takePendingTerminalLaunch(target)).toEqual({
92 cwd: "/repo/worktrees/feature",
93 worktreePath: "/repo/worktrees/feature",
94 env: { FOO: "bar" },
95 initialInput: "pnpm dev\r",
96 });
97 expect(peekPendingTerminalLaunch(target)).toBeNull();
98 });
99
100 it("keeps pending launches isolated per terminal target", () => {
101 const primaryTarget = {
102 environmentId: EnvironmentId.make("env-1"),
103 threadId: ThreadId.make("thread-1"),
104 terminalId: "term-2",
105 };
106 const otherTarget = {
107 environmentId: EnvironmentId.make("env-1"),
108 threadId: ThreadId.make("thread-1"),
109 terminalId: "term-3",
110 };
111
112 stagePendingTerminalLaunch({
113 target: primaryTarget,
114 launch: {
115 cwd: "/repo/root",
116 worktreePath: null,
117 initialInput: "pnpm i\r",
118 },
119 });
120
121 expect(peekPendingTerminalLaunch(otherTarget)).toBeNull();
122 expect(takePendingTerminalLaunch(otherTarget)).toBeNull();
123 expect(takePendingTerminalLaunch(primaryTarget)).toEqual({
124 cwd: "/repo/root",
125 worktreePath: null,
126 env: undefined,
127 initialInput: "pnpm i\r",
128 });
129 });
130});
apps/mobile/src/features/terminal/terminalLaunchContext.ts created+90
...@@ -0,0 +1,90 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2
3interface TerminalLocationLike {
4 readonly cwd: string;
5 readonly worktreePath: string | null;
6}
7
8interface PendingTerminalLaunchTarget {
9 readonly environmentId: EnvironmentId;
10 readonly threadId: ThreadId;
11 readonly terminalId: string;
12}
13
14export interface PendingTerminalLaunch {
15 readonly cwd: string;
16 readonly worktreePath: string | null;
17 readonly env?: Record<string, string>;
18 readonly initialInput?: string;
19}
20
21const pendingTerminalLaunches = new Map<string, PendingTerminalLaunch>();
22
23function pendingTerminalLaunchKey(input: PendingTerminalLaunchTarget): string {
24 return `${input.environmentId}:${input.threadId}:${input.terminalId}`;
25}
26
27export function stagePendingTerminalLaunch(input: {
28 readonly target: PendingTerminalLaunchTarget;
29 readonly launch: PendingTerminalLaunch;
30}) {
31 pendingTerminalLaunches.set(pendingTerminalLaunchKey(input.target), {
32 cwd: input.launch.cwd,
33 worktreePath: input.launch.worktreePath,
34 env: input.launch.env ? { ...input.launch.env } : undefined,
35 initialInput: input.launch.initialInput,
36 });
37}
38
39export function peekPendingTerminalLaunch(
40 target: PendingTerminalLaunchTarget,
41): PendingTerminalLaunch | null {
42 return pendingTerminalLaunches.get(pendingTerminalLaunchKey(target)) ?? null;
43}
44
45export function takePendingTerminalLaunch(
46 target: PendingTerminalLaunchTarget,
47): PendingTerminalLaunch | null {
48 const key = pendingTerminalLaunchKey(target);
49 const launch = pendingTerminalLaunches.get(key) ?? null;
50 if (launch) {
51 pendingTerminalLaunches.delete(key);
52 }
53
54 return launch;
55}
56
57export function resolvePreferredThreadWorktreePath(input: {
58 readonly threadShellWorktreePath: string | null;
59 readonly threadDetailWorktreePath: string | null;
60}): string | null {
61 return input.threadDetailWorktreePath ?? input.threadShellWorktreePath ?? null;
62}
63
64export function resolveTerminalOpenLocation(input: {
65 readonly terminalLocation: TerminalLocationLike | null;
66 readonly activeSessionLocation: TerminalLocationLike | null;
67 readonly workspaceRoot: string;
68 readonly threadShellWorktreePath: string | null;
69 readonly threadDetailWorktreePath: string | null;
70}): {
71 readonly cwd: string;
72 readonly worktreePath: string | null;
73} {
74 const preferredThreadWorktreePath = resolvePreferredThreadWorktreePath({
75 threadShellWorktreePath: input.threadShellWorktreePath,
76 threadDetailWorktreePath: input.threadDetailWorktreePath,
77 });
78
79 return {
80 cwd:
81 input.terminalLocation?.cwd ??
82 input.activeSessionLocation?.cwd ??
83 preferredThreadWorktreePath ??
84 input.workspaceRoot,
85 worktreePath:
86 input.terminalLocation?.worktreePath ??
87 input.activeSessionLocation?.worktreePath ??
88 preferredThreadWorktreePath,
89 };
90}
apps/mobile/src/features/terminal/terminalMenu.test.ts created+165
...@@ -0,0 +1,165 @@
1import { describe, expect, it } from "vitest";
2
3import type { KnownTerminalSession } from "@t3tools/client-runtime";
4import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
5
6import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
7
8import {
9 buildTerminalMenuSessions,
10 nextOpenTerminalId,
11 nextTerminalId,
12 resolveProjectScriptTerminalId,
13} from "./terminalMenu";
14
15function makeKnownSession(input: {
16 readonly terminalId: string;
17 readonly status: KnownTerminalSession["state"]["status"];
18 readonly cwd?: string | null;
19 readonly updatedAt?: string | null;
20}): KnownTerminalSession {
21 return {
22 target: {
23 environmentId: EnvironmentId.make("env-1"),
24 threadId: ThreadId.make("thread-1"),
25 terminalId: input.terminalId,
26 },
27 state: {
28 summary: input.cwd
29 ? {
30 threadId: "thread-1",
31 terminalId: input.terminalId,
32 cwd: input.cwd,
33 worktreePath: input.cwd,
34 status: input.status === "closed" ? "error" : input.status,
35 pid: input.status === "running" ? 123 : null,
36 exitCode: null,
37 exitSignal: null,
38 hasRunningSubprocess: false,
39 label: getTerminalLabel(input.terminalId),
40 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",
41 }
42 : null,
43 buffer: "",
44 status: input.status,
45 error: null,
46 hasRunningSubprocess: false,
47 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",
48 version: 1,
49 },
50 };
51}
52
53describe("buildTerminalMenuSessions", () => {
54 it("only lists server-known sessions that are running or starting (plus current)", () => {
55 expect(
56 buildTerminalMenuSessions({
57 knownSessions: [
58 makeKnownSession({
59 terminalId: "term-3",
60 status: "running",
61 cwd: "/workspace/feature",
62 updatedAt: "2026-04-15T20:05:00.000Z",
63 }),
64 makeKnownSession({
65 terminalId: "term-2",
66 status: "exited",
67 cwd: "/workspace/exited",
68 updatedAt: "2026-04-15T20:06:00.000Z",
69 }),
70 ],
71 workspaceRoot: "/workspace/root",
72 }),
73 ).toEqual([
74 {
75 terminalId: "term-3",
76 cwd: "/workspace/feature",
77 status: "running",
78 hasRunningSubprocess: false,
79 displayLabel: "Terminal 3",
80 updatedAt: "2026-04-15T20:05:00.000Z",
81 },
82 ]);
83 });
84
85 it("keeps the current terminal visible even if it is no longer running", () => {
86 expect(
87 buildTerminalMenuSessions({
88 knownSessions: [],
89 workspaceRoot: "/workspace/root",
90 currentSession: {
91 terminalId: "term-4",
92 cwd: "/workspace/exited",
93 status: "exited",
94 hasRunningSubprocess: false,
95 displayLabel: "Terminal 4",
96 updatedAt: "2026-04-15T20:07:00.000Z",
97 },
98 }),
99 ).toEqual([
100 {
101 terminalId: "term-4",
102 cwd: "/workspace/exited",
103 status: "exited",
104 hasRunningSubprocess: false,
105 displayLabel: "Terminal 4",
106 updatedAt: "2026-04-15T20:07:00.000Z",
107 },
108 ]);
109 });
110});
111
112describe("nextTerminalId", () => {
113 it("uses the primary id when no terminals are listed yet", () => {
114 expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID);
115 });
116
117 it("allocates term-2 when only the primary shell exists", () => {
118 expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2");
119 });
120});
121
122describe("nextOpenTerminalId", () => {
123 it("matches nextTerminalId when not on a terminal route", () => {
124 expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID);
125 expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2");
126 });
127
128 it("avoids the mounted primary tab when the session list is still empty", () => {
129 expect(
130 nextOpenTerminalId({
131 listedTerminalIds: [],
132 activeRouteTerminalId: DEFAULT_TERMINAL_ID,
133 }),
134 ).toBe("term-2");
135 });
136
137 it("does not double-count when the route id is already listed", () => {
138 expect(
139 nextOpenTerminalId({
140 listedTerminalIds: [DEFAULT_TERMINAL_ID],
141 activeRouteTerminalId: DEFAULT_TERMINAL_ID,
142 }),
143 ).toBe("term-2");
144 });
145});
146
147describe("resolveProjectScriptTerminalId", () => {
148 it("reuses the default shell when no terminal is running", () => {
149 expect(
150 resolveProjectScriptTerminalId({
151 existingTerminalIds: [DEFAULT_TERMINAL_ID],
152 hasRunningTerminal: false,
153 }),
154 ).toBe(DEFAULT_TERMINAL_ID);
155 });
156
157 it("opens a new terminal when a shell is already running", () => {
158 expect(
159 resolveProjectScriptTerminalId({
160 existingTerminalIds: [DEFAULT_TERMINAL_ID, "term-2", "term-4"],
161 hasRunningTerminal: true,
162 }),
163 ).toBe("term-3");
164 });
165});
apps/mobile/src/features/terminal/terminalMenu.ts created+139
...@@ -0,0 +1,139 @@
1import type { KnownTerminalSession } from "@t3tools/client-runtime";
2import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts";
3import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
4import * as Arr from "effect/Array";
5import * as Order from "effect/Order";
6
7export {
8 getTerminalLabel,
9 nextTerminalId,
10 resolveTerminalSessionLabel,
11} from "@t3tools/shared/terminalLabels";
12
13export interface TerminalMenuSession {
14 readonly terminalId: string;
15 readonly cwd: string | null;
16 readonly status: "starting" | "running" | "exited" | "error" | "closed";
17 readonly hasRunningSubprocess: boolean;
18 /** Server-authoritative title with the same fallback rules as web. */
19 readonly displayLabel: string;
20 readonly updatedAt: string | null;
21}
22
23const terminalMenuSessionOrder = Order.make<TerminalMenuSession>((left, right) => {
24 const comparison = left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true });
25 if (comparison === 0) {
26 return 0;
27 }
28 return comparison < 0 ? -1 : 1;
29});
30
31export function basename(input: string | null): string | null {
32 if (!input) {
33 return null;
34 }
35
36 const normalized = input.replace(/\/+$/, "");
37 if (normalized.length === 0) {
38 return "/";
39 }
40
41 const segments = normalized.split("/");
42 return segments[segments.length - 1] ?? normalized;
43}
44
45export function getTerminalStatusLabel(input: {
46 readonly status: TerminalMenuSession["status"];
47 readonly hasRunningSubprocess?: boolean;
48}): string {
49 if (input.status === "running") {
50 return input.hasRunningSubprocess ? "Task running" : "Ready";
51 }
52 if (input.status === "starting") {
53 return "Starting";
54 }
55 if (input.status === "exited") {
56 return "Exited";
57 }
58 if (input.status === "error") {
59 return "Error";
60 }
61
62 return "Not started";
63}
64
65/**
66 * Picks an id for "open another shell". Counts the terminal screen already mounted
67 * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route
68 * still advances to `term-2` instead of `replace`-navigating to the same `default` tab.
69 */
70export function nextOpenTerminalId(input: {
71 readonly listedTerminalIds: ReadonlyArray<string>;
72 readonly activeRouteTerminalId?: string | null;
73}): string {
74 const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0);
75 const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null;
76
77 if (!routeId || listed.includes(routeId)) {
78 return nextTerminalId(listed);
79 }
80
81 return nextTerminalId([...listed, routeId]);
82}
83
84export function buildTerminalMenuSessions(input: {
85 readonly knownSessions: ReadonlyArray<KnownTerminalSession>;
86 readonly workspaceRoot: string | null;
87 readonly currentSession?: TerminalMenuSession | null;
88}): ReadonlyArray<TerminalMenuSession> {
89 const sessionsById = new Map<string, TerminalMenuSession>();
90
91 for (const session of input.knownSessions) {
92 if (
93 session.state.status !== "running" &&
94 session.state.status !== "starting" &&
95 session.target.terminalId !== input.currentSession?.terminalId
96 ) {
97 continue;
98 }
99
100 sessionsById.set(session.target.terminalId, {
101 terminalId: session.target.terminalId,
102 cwd: session.state.summary?.cwd ?? input.workspaceRoot,
103 status: session.state.status,
104 hasRunningSubprocess: session.state.hasRunningSubprocess,
105 displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
106 updatedAt: session.state.updatedAt,
107 });
108 }
109
110 if (input.currentSession && !sessionsById.has(input.currentSession.terminalId)) {
111 sessionsById.set(input.currentSession.terminalId, input.currentSession);
112 }
113
114 return Arr.sort(sessionsById.values(), terminalMenuSessionOrder);
115}
116
117export function resolveProjectScriptTerminalId(input: {
118 readonly existingTerminalIds: ReadonlyArray<string>;
119 readonly hasRunningTerminal: boolean;
120}): string {
121 if (!input.hasRunningTerminal) {
122 return DEFAULT_TERMINAL_ID;
123 }
124
125 return nextTerminalId(input.existingTerminalIds);
126}
127
128export function projectScriptMenuLabel(script: ProjectScript): string {
129 return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name;
130}
131
132export function projectScriptMenuIcon(icon: ProjectScript["icon"]) {
133 if (icon === "test") return "flask";
134 if (icon === "lint") return "checklist";
135 if (icon === "configure") return "wrench.and.screwdriver";
136 if (icon === "build") return "hammer";
137 if (icon === "debug") return "ladybug";
138 return "play";
139}
apps/mobile/src/features/terminal/terminalPreferences.test.ts created+28
...@@ -0,0 +1,28 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 DEFAULT_TERMINAL_FONT_SIZE,
5 MAX_TERMINAL_FONT_SIZE,
6 MIN_TERMINAL_FONT_SIZE,
7 normalizeTerminalFontSize,
8} from "./terminalPreferences";
9
10describe("normalizeTerminalFontSize", () => {
11 it("returns the default size for missing or invalid values", () => {
12 expect(normalizeTerminalFontSize(undefined)).toBe(DEFAULT_TERMINAL_FONT_SIZE);
13 expect(normalizeTerminalFontSize(null)).toBe(DEFAULT_TERMINAL_FONT_SIZE);
14 expect(normalizeTerminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE);
15 });
16
17 it("clamps below the minimum", () => {
18 expect(normalizeTerminalFontSize(MIN_TERMINAL_FONT_SIZE - 4)).toBe(MIN_TERMINAL_FONT_SIZE);
19 });
20
21 it("clamps above the maximum", () => {
22 expect(normalizeTerminalFontSize(MAX_TERMINAL_FONT_SIZE + 4)).toBe(MAX_TERMINAL_FONT_SIZE);
23 });
24
25 it("preserves in-range values", () => {
26 expect(normalizeTerminalFontSize(9.5)).toBe(9.5);
27 });
28});
apps/mobile/src/features/terminal/terminalPreferences.ts created+12
...@@ -0,0 +1,12 @@
1export const DEFAULT_TERMINAL_FONT_SIZE = 10;
2export const TERMINAL_FONT_SIZE_STEP = 0.5;
3export const MIN_TERMINAL_FONT_SIZE = 6;
4export const MAX_TERMINAL_FONT_SIZE = 14;
5
6export function normalizeTerminalFontSize(value: number | null | undefined): number {
7 if (typeof value !== "number" || !Number.isFinite(value)) {
8 return DEFAULT_TERMINAL_FONT_SIZE;
9 }
10
11 return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value));
12}
apps/mobile/src/features/terminal/terminalRouteBootstrap.ts created+35
...@@ -0,0 +1,35 @@
1export function resolveTerminalRouteBootstrap(input: {
2 readonly hasThread: boolean;
3 readonly hasWorkspaceRoot: boolean;
4 readonly hasOpened: boolean;
5 readonly requestedTerminalId: string | null;
6 readonly currentTerminalId: string;
7 readonly runningTerminalId: string | null;
8 readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed";
9 /** True once the attach stream has populated scrollback (`buffer` non-empty), not merely metadata. */
10 readonly hasCurrentTerminalHydration: boolean;
11}):
12 | { readonly kind: "idle" }
13 | { readonly kind: "redirect"; readonly terminalId: string }
14 | { readonly kind: "open" } {
15 if (!input.hasThread || !input.hasWorkspaceRoot || input.hasOpened) {
16 return { kind: "idle" };
17 }
18
19 if (
20 input.requestedTerminalId === null &&
21 input.runningTerminalId !== null &&
22 input.runningTerminalId !== input.currentTerminalId
23 ) {
24 return { kind: "redirect", terminalId: input.runningTerminalId };
25 }
26
27 if (
28 (input.currentTerminalStatus === "running" || input.currentTerminalStatus === "starting") &&
29 input.hasCurrentTerminalHydration
30 ) {
31 return { kind: "idle" };
32 }
33
34 return { kind: "open" };
35}
apps/mobile/src/features/terminal/terminalTheme.test.ts created+36
...@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vitest";
2
3import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme";
4
5describe("getPierreTerminalTheme", () => {
6 it("returns the Pierre light terminal palette", () => {
7 expect(getPierreTerminalTheme("light")).toMatchObject({
8 background: "#f2f2f7",
9 foreground: "#6C6C71",
10 cursorForeground: "#009fff",
11 cursorBackground: "#f2f2f7",
12 });
13 });
14
15 it("returns the Pierre dark terminal palette", () => {
16 expect(getPierreTerminalTheme("dark")).toMatchObject({
17 background: "#0a0a0a",
18 foreground: "#adadb1",
19 cursorForeground: "#009fff",
20 cursorBackground: "#0a0a0a",
21 });
22 });
23});
24
25describe("buildGhosttyThemeConfig", () => {
26 it("serializes theme colors into a ghostty config file", () => {
27 const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark"));
28
29 expect(config).toContain("background = #0a0a0a");
30 expect(config).toContain("foreground = #adadb1");
31 expect(config).toContain("cursor-color = #009fff");
32 expect(config).toContain("palette = 0=#141415");
33 expect(config).toContain("palette = 15=#c6c6c8");
34 expect(config.endsWith("\n")).toBe(true);
35 });
36});
apps/mobile/src/features/terminal/terminalTheme.ts created+86
...@@ -0,0 +1,86 @@
1export type TerminalAppearanceScheme = "light" | "dark";
2
3export interface TerminalTheme {
4 readonly background: string;
5 readonly foreground: string;
6 readonly mutedForeground: string;
7 readonly border: string;
8 readonly cursorForeground: string;
9 readonly cursorBackground: string;
10 readonly palette: readonly string[];
11}
12
13const PIERRE_LIGHT_THEME: TerminalTheme = {
14 // Pierre terminal palette with the app's shared screen background.
15 background: "#f2f2f7",
16 foreground: "#6C6C71",
17 mutedForeground: "#8E8E95",
18 border: "#eeeeef",
19 cursorForeground: "#009fff",
20 cursorBackground: "#f2f2f7",
21 palette: [
22 "#1F1F21",
23 "#ff2e3f",
24 "#0dbe4e",
25 "#ffca00",
26 "#009fff",
27 "#c635e4",
28 "#08c0ef",
29 "#c6c6c8",
30 "#1F1F21",
31 "#ff2e3f",
32 "#0dbe4e",
33 "#ffca00",
34 "#009fff",
35 "#c635e4",
36 "#08c0ef",
37 "#c6c6c8",
38 ],
39};
40
41const PIERRE_DARK_THEME: TerminalTheme = {
42 // Pierre terminal palette with the app's shared screen background.
43 background: "#0a0a0a",
44 foreground: "#adadb1",
45 mutedForeground: "#8E8E95",
46 border: "#2e2e30",
47 cursorForeground: "#009fff",
48 cursorBackground: "#0a0a0a",
49 palette: [
50 "#141415",
51 "#ff2e3f",
52 "#0dbe4e",
53 "#ffca00",
54 "#009fff",
55 "#c635e4",
56 "#08c0ef",
57 "#c6c6c8",
58 "#141415",
59 "#ff2e3f",
60 "#0dbe4e",
61 "#ffca00",
62 "#009fff",
63 "#c635e4",
64 "#08c0ef",
65 "#c6c6c8",
66 ],
67};
68
69export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme {
70 return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME;
71}
72
73export function buildGhosttyThemeConfig(theme: TerminalTheme): string {
74 const lines = [
75 `background = ${theme.background}`,
76 `foreground = ${theme.foreground}`,
77 `cursor-color = ${theme.cursorForeground}`,
78 `cursor-text = ${theme.cursorBackground}`,
79 ];
80
81 for (const [index, color] of theme.palette.entries()) {
82 lines.push(`palette = ${index}=${color}`);
83 }
84
85 return `${lines.join("\n")}\n`;
86}
apps/mobile/src/features/terminal/terminalUiState.test.ts created+53
...@@ -0,0 +1,53 @@
1import { beforeEach, describe, expect, it } from "vitest";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3
4import {
5 cacheTerminalFontSize,
6 cacheTerminalGridSize,
7 getCachedTerminalFontSize,
8 getCachedTerminalGridSize,
9 resetTerminalUiStateCaches,
10} from "./terminalUiState";
11
12describe("terminalUiState", () => {
13 beforeEach(() => {
14 resetTerminalUiStateCaches();
15 });
16
17 it("caches terminal font size using the shared normalization rules", () => {
18 expect(getCachedTerminalFontSize()).toBeNull();
19 expect(cacheTerminalFontSize(8.5)).toBe(8.5);
20 expect(getCachedTerminalFontSize()).toBe(8.5);
21 expect(cacheTerminalFontSize(100)).toBe(14);
22 expect(getCachedTerminalFontSize()).toBe(14);
23 });
24
25 it("stores terminal grid sizes per terminal target", () => {
26 const primaryTarget = {
27 environmentId: EnvironmentId.make("env-1"),
28 threadId: ThreadId.make("thread-1"),
29 terminalId: "default",
30 };
31 const otherTarget = {
32 environmentId: EnvironmentId.make("env-1"),
33 threadId: ThreadId.make("thread-1"),
34 terminalId: "term-2",
35 };
36
37 expect(getCachedTerminalGridSize(primaryTarget)).toBeNull();
38 expect(
39 cacheTerminalGridSize(primaryTarget, {
40 cols: 107.9,
41 rows: 33.2,
42 }),
43 ).toEqual({
44 cols: 107,
45 rows: 33,
46 });
47 expect(getCachedTerminalGridSize(primaryTarget)).toEqual({
48 cols: 107,
49 rows: 33,
50 });
51 expect(getCachedTerminalGridSize(otherTarget)).toBeNull();
52 });
53});
apps/mobile/src/features/terminal/terminalUiState.ts created+52
...@@ -0,0 +1,52 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2
3import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences";
4
5export interface TerminalGridSize {
6 readonly cols: number;
7 readonly rows: number;
8}
9
10export interface TerminalUiStateTarget {
11 readonly environmentId: EnvironmentId;
12 readonly threadId: ThreadId;
13 readonly terminalId: string;
14}
15
16const terminalGridSizeCache = new Map<string, TerminalGridSize>();
17let cachedTerminalFontSize: number | null = null;
18
19function terminalUiStateKey(target: TerminalUiStateTarget): string {
20 return `${target.environmentId}:${target.threadId}:${target.terminalId}`;
21}
22
23export function getCachedTerminalFontSize(): number | null {
24 return cachedTerminalFontSize;
25}
26
27export function cacheTerminalFontSize(value: number | null | undefined): number {
28 const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE);
29 cachedTerminalFontSize = normalized;
30 return normalized;
31}
32
33export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null {
34 return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null;
35}
36
37export function cacheTerminalGridSize(
38 target: TerminalUiStateTarget,
39 size: TerminalGridSize,
40): TerminalGridSize {
41 const normalized = {
42 cols: Math.max(1, Math.floor(size.cols)),
43 rows: Math.max(1, Math.floor(size.rows)),
44 };
45 terminalGridSizeCache.set(terminalUiStateKey(target), normalized);
46 return normalized;
47}
48
49export function resetTerminalUiStateCaches() {
50 cachedTerminalFontSize = null;
51 terminalGridSizeCache.clear();
52}
apps/mobile/src/features/threads/ComposerCommandPopover.tsx created+211
...@@ -0,0 +1,211 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
3import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
4import { SymbolView } from "expo-symbols";
5import { memo } from "react";
6import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";
7
8import { AppText as Text } from "../../components/AppText";
9
10export type ComposerCommandItem =
11 | {
12 readonly id: string;
13 readonly type: "path";
14 readonly path: string;
15 readonly kind: "file" | "directory";
16 readonly label: string;
17 readonly description: string;
18 }
19 | {
20 readonly id: string;
21 readonly type: "slash-command";
22 readonly command: string;
23 readonly label: string;
24 readonly description: string;
25 }
26 | {
27 readonly id: string;
28 readonly type: "provider-slash-command";
29 readonly command: ServerProviderSlashCommand;
30 readonly label: string;
31 readonly description: string;
32 }
33 | {
34 readonly id: string;
35 readonly type: "skill";
36 readonly skill: ServerProviderSkill;
37 readonly label: string;
38 readonly description: string;
39 };
40
41interface ComposerCommandPopoverProps {
42 readonly items: ReadonlyArray<ComposerCommandItem>;
43 readonly triggerKind: ComposerTriggerKind | null;
44 readonly isLoading: boolean;
45 readonly onSelect: (item: ComposerCommandItem) => void;
46}
47
48function PopoverSurface(props: {
49 readonly children: React.ReactNode;
50 readonly isDarkMode: boolean;
51 readonly style?: ViewStyle;
52}) {
53 const baseStyle: ViewStyle = {
54 borderRadius: 16,
55 overflow: "hidden",
56 ...props.style,
57 };
58
59 if (isLiquidGlassSupported) {
60 return (
61 <LiquidGlassView
62 effect="clear"
63 interactive={false}
64 tintColor={props.isDarkMode ? "rgba(30,30,32,0.95)" : "rgba(255,255,255,0.92)"}
65 colorScheme={props.isDarkMode ? "dark" : "light"}
66 style={baseStyle}
67 >
68 {props.children}
69 </LiquidGlassView>
70 );
71 }
72
73 return (
74 <View
75 style={[
76 baseStyle,
77 {
78 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
79 borderWidth: 1,
80 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
81 },
82 ]}
83 >
84 {props.children}
85 </View>
86 );
87}
88
89function itemIcon(item: ComposerCommandItem) {
90 switch (item.type) {
91 case "path":
92 return item.kind === "directory" ? ("folder" as const) : ("doc" as const);
93 case "slash-command":
94 case "provider-slash-command":
95 return "terminal" as const;
96 case "skill":
97 return "cube" as const;
98 }
99}
100
101function groupLabel(triggerKind: ComposerTriggerKind | null): string | null {
102 switch (triggerKind) {
103 case "slash-command":
104 return "Commands";
105 case "skill":
106 return "Skills";
107 case "path":
108 return "Files";
109 default:
110 return null;
111 }
112}
113
114function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string {
115 if (isLoading) {
116 return triggerKind === "path" ? "Searching files…" : "Loading…";
117 }
118 switch (triggerKind) {
119 case "path":
120 return "No matching files or folders.";
121 case "skill":
122 return "No skills found.";
123 case "slash-command":
124 return "No matching commands.";
125 default:
126 return "No results.";
127 }
128}
129
130const CommandRow = memo(function CommandRow(props: {
131 readonly item: ComposerCommandItem;
132 readonly onPress: () => void;
133 readonly isLast: boolean;
134}) {
135 const iconName = itemIcon(props.item);
136 const iconColor = "#a1a1aa";
137
138 return (
139 <Pressable
140 onPress={props.onPress}
141 style={({ pressed }) => ({
142 flexDirection: "row",
143 alignItems: "center",
144 paddingHorizontal: 14,
145 paddingVertical: 10,
146 gap: 10,
147 opacity: pressed ? 0.6 : 1,
148 borderBottomWidth: props.isLast ? 0 : 0.5,
149 borderBottomColor: "rgba(255,255,255,0.1)",
150 })}
151 >
152 <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" />
153 <Text
154 className="text-[14px] font-t3-medium text-foreground"
155 numberOfLines={1}
156 style={{ flexShrink: 0 }}
157 >
158 {props.item.label}
159 </Text>
160 {props.item.description ? (
161 <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, fontSize: 12, color: "#a1a1aa" }}>
162 {props.item.description}
163 </Text>
164 ) : null}
165 </Pressable>
166 );
167});
168
169export const ComposerCommandPopover = memo(function ComposerCommandPopover(
170 props: ComposerCommandPopoverProps,
171) {
172 const isDarkMode = useColorScheme() === "dark";
173 const label = groupLabel(props.triggerKind);
174
175 return (
176 <PopoverSurface isDarkMode={isDarkMode}>
177 {label ? (
178 <View style={{ paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 }}>
179 <Text
180 className="text-[10px] font-t3-bold text-foreground-muted"
181 style={{ letterSpacing: 0.8, textTransform: "uppercase" }}
182 >
183 {label}
184 </Text>
185 </View>
186 ) : null}
187 {props.items.length > 0 ? (
188 <ScrollView
189 style={{ maxHeight: 180 }}
190 keyboardShouldPersistTaps="always"
191 showsVerticalScrollIndicator={false}
192 >
193 {props.items.map((item, index) => (
194 <CommandRow
195 key={item.id}
196 item={item}
197 onPress={() => props.onSelect(item)}
198 isLast={index === props.items.length - 1}
199 />
200 ))}
201 </ScrollView>
202 ) : (
203 <View style={{ paddingHorizontal: 14, paddingVertical: 10 }}>
204 <Text className="text-[12px] text-foreground-tertiary">
205 {emptyText(props.triggerKind, props.isLoading)}
206 </Text>
207 </View>
208 )}
209 </PopoverSurface>
210 );
211});
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx created+121
...@@ -0,0 +1,121 @@
1import * as Haptics from "expo-haptics";
2import { SymbolView } from "expo-symbols";
3import { useCallback, useEffect, useRef } from "react";
4import { ActivityIndicator, Linking, Pressable, View } from "react-native";
5import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7
8import { AppText as Text } from "../../components/AppText";
9import { useThemeColor } from "../../lib/useThemeColor";
10import type { GitActionProgress } from "../../state/use-vcs-action-state";
11
12export function GitActionProgressOverlay(props: {
13 readonly progress: GitActionProgress;
14 readonly onDismiss: () => void;
15}) {
16 const { progress, onDismiss } = props;
17 const insets = useSafeAreaInsets();
18 const prevPhaseRef = useRef(progress.phase);
19
20 useEffect(() => {
21 const prev = prevPhaseRef.current;
22 prevPhaseRef.current = progress.phase;
23
24 if (prev === "running" && progress.phase === "success") {
25 Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
26 } else if (prev === "running" && progress.phase === "error") {
27 Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
28 }
29 }, [progress.phase]);
30
31 const handlePress = useCallback(() => {
32 if (progress.prUrl) {
33 void Linking.openURL(progress.prUrl);
34 return;
35 }
36 if (progress.phase === "success" || progress.phase === "error") {
37 onDismiss();
38 }
39 }, [onDismiss, progress.phase, progress.prUrl]);
40
41 if (progress.phase === "idle") {
42 return null;
43 }
44
45 return (
46 <Animated.View
47 entering={FadeIn.duration(200)}
48 exiting={FadeOut.duration(150)}
49 style={{ top: insets.top + 48, left: 12, right: 12, position: "absolute", zIndex: 100 }}
50 pointerEvents="box-none"
51 >
52 <Pressable onPress={handlePress}>
53 <OverlayContent progress={progress} />
54 </Pressable>
55 </Animated.View>
56 );
57}
58
59function OverlayContent(props: { readonly progress: GitActionProgress }) {
60 const { progress } = props;
61 const iconColor = useThemeColor("--color-icon");
62
63 const bgClass =
64 progress.phase === "error"
65 ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800"
66 : "bg-card border-border";
67
68 return (
69 <View
70 className={`flex-row items-center gap-2.5 rounded-2xl border px-3.5 py-3 shadow-lg shadow-black/10 ${bgClass}`}
71 >
72 <OverlayIcon phase={progress.phase} iconColor={iconColor} />
73
74 <View className="flex-1 gap-0.5">
75 {progress.label ? (
76 <Text className="text-[13px] font-t3-bold text-foreground" numberOfLines={1}>
77 {progress.label}
78 </Text>
79 ) : null}
80 {progress.description ? (
81 <Text className="text-[11px] text-foreground-muted" numberOfLines={1}>
82 {progress.description}
83 </Text>
84 ) : null}
85 </View>
86
87 {progress.prUrl ? (
88 <SymbolView name="arrow.up.right" size={13} tintColor={iconColor} type="monochrome" />
89 ) : null}
90 </View>
91 );
92}
93
94function OverlayIcon(props: {
95 readonly phase: GitActionProgress["phase"];
96 readonly iconColor: ReturnType<typeof useThemeColor>;
97}) {
98 switch (props.phase) {
99 case "running":
100 return <ActivityIndicator size="small" />;
101 case "success":
102 return (
103 <View className="h-6 w-6 items-center justify-center rounded-full bg-green-500">
104 <SymbolView name="checkmark" size={12} tintColor="white" type="monochrome" />
105 </View>
106 );
107 case "error":
108 return (
109 <View className="h-6 w-6 items-center justify-center rounded-full bg-red-500">
110 <SymbolView
111 name="exclamationmark.triangle"
112 size={12}
113 tintColor="white"
114 type="monochrome"
115 />
116 </View>
117 );
118 default:
119 return null;
120 }
121}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx created+494
...@@ -0,0 +1,494 @@
1import { useRouter } from "expo-router";
2import { TextInputWrapper } from "expo-paste-input";
3import { useCallback, useEffect, useMemo } from "react";
4import { View } from "react-native";
5import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
8
9import { EnvironmentId, type ModelSelection } from "@t3tools/contracts";
10
11import { AppTextInput as TextInput } from "../../components/AppText";
12import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
13import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
14import { ProviderIcon } from "../../components/ProviderIcon";
15
16import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
17import { buildThreadRoutePath } from "../../lib/routes";
18import { useRemoteCatalog } from "../../state/use-remote-catalog";
19import { useNativePaste } from "../../lib/useNativePaste";
20import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";
21import { NewTaskSheetHeader } from "./NewTaskSheetHeader";
22import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
23import { useProjectActions } from "./use-project-actions";
24
25function withModelSelectionOption(
26 selection: ModelSelection,
27 id: string,
28 value: string | boolean | undefined,
29): ModelSelection {
30 const options = (selection.options ?? []).filter((option) => option.id !== id);
31 return {
32 ...selection,
33 options: value === undefined ? options : [...options, { id, value }],
34 };
35}
36
37export function NewTaskDraftScreen(props: {
38 readonly initialProjectRef?: {
39 readonly environmentId?: string;
40 readonly projectId?: string;
41 };
42}) {
43 const { projects } = useRemoteCatalog();
44 const { onCreateThreadWithOptions } = useProjectActions();
45 const flow = useNewTaskFlow();
46 const router = useRouter();
47 const insets = useSafeAreaInsets();
48 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
49 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
50 const { logicalProjects, selectedProject, setProject } = flow;
51
52 const borderColor = useThemeColor("--color-border");
53
54 useEffect(() => {
55 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {
56 const directProject =
57 projects.find(
58 (project) =>
59 project.environmentId === props.initialProjectRef?.environmentId &&
60 project.id === props.initialProjectRef?.projectId,
61 ) ?? null;
62
63 if (directProject) {
64 setProject(directProject);
65 return;
66 }
67 }
68
69 if (selectedProject) {
70 return;
71 }
72
73 if (logicalProjects.length === 1) {
74 setProject(logicalProjects[0]!.project);
75 return;
76 }
77
78 router.replace("/new");
79 }, [
80 logicalProjects,
81 projects,
82 props.initialProjectRef?.environmentId,
83 props.initialProjectRef?.projectId,
84 router,
85 selectedProject,
86 setProject,
87 ]);
88
89 useEffect(() => {
90 if (!selectedProject) {
91 return;
92 }
93 void flow.loadBranches();
94 }, [flow, selectedProject]);
95
96 const environmentMenuActions = useMemo(
97 () =>
98 flow.environments.map((environment) => ({
99 id: `environment:${environment.environmentId}`,
100 title: environment.environmentLabel,
101 state:
102 flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined,
103 })),
104 [flow.environments, flow.selectedEnvironmentId],
105 );
106
107 const modelMenuActions = useMemo(
108 () =>
109 flow.providerGroups.map((group) => ({
110 id: `provider:${group.providerKey}`,
111 title: group.providerLabel,
112 subtitle: group.models.find(
113 (model) =>
114 flow.selectedModel &&
115 model.selection.instanceId === flow.selectedModel.instanceId &&
116 model.selection.model === flow.selectedModel.model,
117 )?.label,
118 subactions: group.models.map((option) => ({
119 id: `model:${option.key}`,
120 title: option.label,
121 state:
122 flow.selectedModel &&
123 option.selection.instanceId === flow.selectedModel.instanceId &&
124 option.selection.model === flow.selectedModel.model
125 ? ("on" as const)
126 : undefined,
127 })),
128 })),
129 [flow.providerGroups, flow.selectedModel],
130 );
131
132 const optionsMenuActions = useMemo(
133 () => [
134 {
135 id: "options-effort",
136 title: "Effort",
137 subtitle: `${flow.effort.charAt(0).toUpperCase()}${flow.effort.slice(1)}`,
138 subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({
139 id: `options:effort:${level}`,
140 title: `${level}${level === "high" ? " (default)" : ""}`,
141 state: flow.effort === level ? ("on" as const) : undefined,
142 })),
143 },
144 {
145 id: "options-fast-mode",
146 title: "Fast Mode",
147 subtitle: flow.fastMode ? "On" : "Off",
148 subactions: ([false, true] as const).map((value) => ({
149 id: `options:fast-mode:${value ? "on" : "off"}`,
150 title: value ? "On" : "Off",
151 state: flow.fastMode === value ? ("on" as const) : undefined,
152 })),
153 },
154 {
155 id: "options-context-window",
156 title: "Context Window",
157 subtitle: flow.contextWindow,
158 subactions: (["200k", "1M"] as const).map((value) => ({
159 id: `options:context-window:${value}`,
160 title: `${value}${value === "1M" ? " (default)" : ""}`,
161 state: flow.contextWindow === value ? ("on" as const) : undefined,
162 })),
163 },
164 {
165 id: "options-runtime",
166 title: "Runtime",
167 subtitle:
168 flow.runtimeMode === "approval-required"
169 ? "Approve actions"
170 : flow.runtimeMode === "auto-accept-edits"
171 ? "Auto-accept edits"
172 : "Full access",
173 subactions: [
174 { id: "options:runtime:approval-required", title: "Approve actions" },
175 { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" },
176 { id: "options:runtime:full-access", title: "Full access" },
177 ].map((option) => {
178 const value = option.id.replace("options:runtime:", "");
179 return {
180 id: option.id,
181 title: option.title,
182 state: flow.runtimeMode === value ? ("on" as const) : undefined,
183 };
184 }),
185 },
186 {
187 id: "options-interaction",
188 title: "Interaction",
189 subtitle: flow.interactionMode === "plan" ? "Plan" : "Default",
190 subactions: [
191 { id: "options:interaction:default", title: "Default" },
192 { id: "options:interaction:plan", title: "Plan" },
193 ].map((option) => {
194 const value = option.id.replace("options:interaction:", "");
195 return {
196 id: option.id,
197 title: option.title,
198 state: flow.interactionMode === value ? ("on" as const) : undefined,
199 };
200 }),
201 },
202 ],
203 [flow.contextWindow, flow.effort, flow.fastMode, flow.interactionMode, flow.runtimeMode],
204 );
205
206 const workspaceMenuActions = useMemo(() => {
207 const branchActions =
208 flow.availableBranches.length === 0
209 ? [
210 {
211 id: "workspace:branch:none",
212 title: flow.branchesLoading ? "Loading branches…" : "No branches available",
213 attributes: { disabled: true },
214 },
215 ]
216 : flow.availableBranches.slice(0, 12).map((branch) => {
217 const badge = branchBadgeLabel({
218 branch,
219 project: flow.selectedProject,
220 });
221
222 return {
223 id: `workspace:branch:${branch.name}`,
224 title: branch.name,
225 subtitle: badge ? badge.toUpperCase() : undefined,
226 state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined,
227 };
228 });
229
230 return [
231 {
232 id: "workspace:mode",
233 title: "Mode",
234 subtitle: flow.workspaceMode === "local" ? "Local" : "Worktree",
235 subactions: (["local", "worktree"] as const).map((value) => ({
236 id: `workspace:mode:${value}`,
237 title: value === "local" ? "Local" : "Worktree",
238 state: flow.workspaceMode === value ? ("on" as const) : undefined,
239 })),
240 },
241 {
242 id: "workspace:branch",
243 title: "Branch",
244 subtitle: flow.selectedBranchName ?? "Choose branch",
245 subactions: branchActions,
246 },
247 ];
248 }, [
249 flow.availableBranches,
250 flow.branchesLoading,
251 flow.selectedBranchName,
252 flow.selectedProject,
253 flow.workspaceMode,
254 ]);
255
256 function handleModelMenuAction(event: string) {
257 if (!event.startsWith("model:")) {
258 return;
259 }
260 // Defer state update so the native menu dismiss animation completes
261 // before re-rendering the menu actions (prevents submenu jump).
262 setTimeout(() => {
263 flow.setSelectedModelKey(event.slice("model:".length));
264 }, 150);
265 }
266
267 function handleEnvironmentMenuAction(event: string) {
268 if (!event.startsWith("environment:")) {
269 return;
270 }
271 flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length)));
272 }
273
274 function handleOptionsMenuAction(event: string) {
275 if (event.startsWith("options:effort:")) {
276 flow.setEffort(event.slice("options:effort:".length) as typeof flow.effort);
277 return;
278 }
279 if (event.startsWith("options:fast-mode:")) {
280 flow.setFastMode(event.endsWith(":on"));
281 return;
282 }
283 if (event.startsWith("options:context-window:")) {
284 flow.setContextWindow(event.slice("options:context-window:".length));
285 return;
286 }
287 if (event.startsWith("options:runtime:")) {
288 flow.setRuntimeMode(
289 event.slice("options:runtime:".length) as Parameters<typeof flow.setRuntimeMode>[0],
290 );
291 return;
292 }
293 if (event.startsWith("options:interaction:")) {
294 flow.setInteractionMode(
295 event.slice("options:interaction:".length) as Parameters<typeof flow.setInteractionMode>[0],
296 );
297 }
298 }
299
300 function handleWorkspaceMenuAction(event: string) {
301 if (event.startsWith("workspace:mode:")) {
302 flow.setWorkspaceMode(
303 event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0],
304 );
305 return;
306 }
307 if (event.startsWith("workspace:branch:")) {
308 const branchName = event.slice("workspace:branch:".length);
309 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);
310 if (branch) {
311 flow.selectBranch(branch);
312 }
313 }
314 }
315
316 async function handlePickImages(): Promise<void> {
317 const result = await pickComposerImages({ existingCount: flow.attachments.length });
318 if (result.images.length > 0) {
319 flow.appendAttachments(result.images);
320 }
321 }
322
323 const handleNativePasteImages = useCallback(
324 async (uris: ReadonlyArray<string>) => {
325 try {
326 const images = await convertPastedImagesToAttachments({
327 uris,
328 existingCount: flow.attachments.length,
329 });
330 if (images.length > 0) {
331 flow.appendAttachments(images);
332 }
333 } catch (error) {
334 console.error("[native paste] error converting images", error);
335 }
336 },
337 [flow],
338 );
339
340 const handleNativePaste = useNativePaste((uris) => {
341 void handleNativePasteImages(uris);
342 });
343
344 async function handleStart(): Promise<void> {
345 if (
346 !flow.selectedProject ||
347 !flow.selectedModel ||
348 flow.prompt.trim().length === 0 ||
349 flow.submitting ||
350 (flow.workspaceMode === "worktree" && !flow.selectedBranchName)
351 ) {
352 return;
353 }
354
355 flow.setSubmitting(true);
356 try {
357 const modelWithOptions: ModelSelection =
358 flow.selectedModelOption?.providerDriver === "claudeAgent"
359 ? withModelSelectionOption(
360 withModelSelectionOption(
361 withModelSelectionOption(flow.selectedModel, "effort", flow.effort),
362 "fastMode",
363 flow.fastMode || undefined,
364 ),
365 "contextWindow",
366 flow.contextWindow,
367 )
368 : flow.selectedModelOption?.providerDriver === "codex"
369 ? withModelSelectionOption(flow.selectedModel, "fastMode", flow.fastMode || undefined)
370 : flow.selectedModel;
371
372 const createdThread = await onCreateThreadWithOptions({
373 project: flow.selectedProject,
374 modelSelection: modelWithOptions,
375 envMode: flow.workspaceMode,
376 branch: flow.selectedBranchName,
377 worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath,
378 runtimeMode: flow.runtimeMode,
379 interactionMode: flow.interactionMode,
380 initialMessageText: flow.prompt.trim(),
381 initialAttachments: flow.attachments,
382 });
383
384 if (createdThread) {
385 router.replace(buildThreadRoutePath(createdThread));
386 }
387 } finally {
388 flow.setSubmitting(false);
389 }
390 }
391
392 if (!selectedProject) {
393 return (
394 <View className="flex-1 bg-sheet">
395 <NewTaskSheetHeader title="Loading task" />
396 </View>
397 );
398 }
399
400 return (
401 <View className="flex-1 bg-sheet">
402 <NewTaskSheetHeader
403 title={selectedProject.title}
404 control={
405 flow.logicalProjects.length > 1
406 ? { icon: "chevron.left", onPress: () => router.back() }
407 : undefined
408 }
409 />
410
411 <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}>
412 <TextInputWrapper
413 onPaste={(payload) => void handleNativePaste(payload)}
414 style={{ flex: 1 }}
415 >
416 <TextInput
417 multiline
418 value={flow.prompt}
419 onChangeText={flow.setPrompt}
420 placeholder={`Describe a coding task in ${selectedProject.title}`}
421 textAlignVertical="top"
422 className="h-full flex-1 border-0 bg-transparent text-[18px] leading-[28px]"
423 style={{ flex: 1 }}
424 />
425 </TextInputWrapper>
426 </View>
427
428 <KeyboardStickyView>
429 <View
430 style={{
431 borderTopWidth: 1,
432 borderTopColor: borderColor,
433 paddingBottom: controlsBottomPadding,
434 }}
435 >
436 {flow.attachments.length > 0 ? (
437 <View style={{ paddingHorizontal: 16, paddingTop: 12 }}>
438 <ComposerAttachmentStrip
439 attachments={flow.attachments}
440 onRemove={flow.removeAttachment}
441 imageSize={88}
442 imageBorderRadius={20}
443 />
444 </View>
445 ) : null}
446 <View className="flex-row items-center justify-between gap-2 px-4 pt-2">
447 <ControlPill icon="plus" onPress={() => void handlePickImages()} />
448 <ControlPillMenu
449 actions={modelMenuActions}
450 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
451 >
452 <ControlPill
453 iconNode={
454 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
455 }
456 />
457 </ControlPillMenu>
458 <ControlPillMenu
459 actions={optionsMenuActions}
460 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
461 >
462 <ControlPill icon="slider.horizontal.3" />
463 </ControlPillMenu>
464 <ControlPillMenu
465 actions={environmentMenuActions}
466 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
467 >
468 <ControlPill icon="desktopcomputer" />
469 </ControlPillMenu>
470 <ControlPillMenu
471 actions={workspaceMenuActions}
472 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
473 >
474 <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" />
475 </ControlPillMenu>
476 <ControlPill
477 icon="arrow.up"
478 label={flow.submitting ? "Starting" : "Start"}
479 onPress={() => void handleStart()}
480 variant="primary"
481 disabled={
482 !flow.selectedProject ||
483 !flow.selectedModel ||
484 flow.prompt.trim().length === 0 ||
485 flow.submitting ||
486 (flow.workspaceMode === "worktree" && !flow.selectedBranchName)
487 }
488 />
489 </View>
490 </View>
491 </KeyboardStickyView>
492 </View>
493 );
494}
apps/mobile/src/features/threads/NewTaskSheetHeader.tsx created+46
...@@ -0,0 +1,46 @@
1import { SymbolView } from "expo-symbols";
2import { Pressable, View } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { useThemeColor } from "../../lib/useThemeColor";
6
7export function NewTaskSheetHeader(props: {
8 readonly eyebrow?: string;
9 readonly title: string;
10 readonly control?: {
11 readonly icon: "chevron.left" | "xmark";
12 readonly onPress: () => void;
13 };
14}) {
15 const iconColor = useThemeColor("--color-icon");
16
17 return (
18 <>
19 <View style={{ minHeight: 16, paddingTop: 8 }} />
20 <View className="items-center gap-1 px-5 pb-3 pt-4">
21 {props.control ? (
22 <Pressable
23 className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
24 style={{ zIndex: 1 }}
25 onPress={props.control.onPress}
26 >
27 <SymbolView
28 name={props.control.icon}
29 size={16}
30 tintColor={iconColor}
31 type="monochrome"
32 weight="medium"
33 />
34 </Pressable>
35 ) : null}
36 <Text
37 className="text-[12px] font-t3-bold uppercase text-foreground-muted"
38 style={{ letterSpacing: 1 }}
39 >
40 {props.eyebrow ?? "New task"}
41 </Text>
42 <Text className="text-[28px] font-t3-bold">{props.title}</Text>
43 </View>
44 </>
45 );
46}
apps/mobile/src/features/threads/PendingApprovalCard.tsx created+57
...@@ -0,0 +1,57 @@
1import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts";
2import { Pressable, View } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import type { PendingApproval } from "../../lib/threadActivity";
6
7export interface PendingApprovalCardProps {
8 readonly approval: PendingApproval;
9 readonly respondingApprovalId: ApprovalRequestId | null;
10 readonly onRespond: (
11 requestId: ApprovalRequestId,
12 decision: ProviderApprovalDecision,
13 ) => Promise<void>;
14}
15
16export function PendingApprovalCard(props: PendingApprovalCardProps) {
17 return (
18 <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80">
19 <Text className="font-t3-bold text-[11px] uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
20 Approval needed
21 </Text>
22 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
23 {props.approval.requestKind}
24 </Text>
25 {props.approval.detail ? (
26 <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400">
27 {props.approval.detail}
28 </Text>
29 ) : null}
30 <View className="flex-row flex-wrap gap-2.5">
31 <Pressable
32 className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3"
33 disabled={props.respondingApprovalId === props.approval.requestId}
34 onPress={() => void props.onRespond(props.approval.requestId, "accept")}
35 >
36 <Text className="font-t3-extrabold text-sm text-white">Allow once</Text>
37 </Pressable>
38 <Pressable
39 className="items-center justify-center rounded-[14px] bg-neutral-200 px-3.5 py-3 dark:bg-neutral-800"
40 disabled={props.respondingApprovalId === props.approval.requestId}
41 onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")}
42 >
43 <Text className="font-t3-bold text-sm text-neutral-950 dark:text-neutral-50">
44 Allow session
45 </Text>
46 </Pressable>
47 <Pressable
48 className="items-center justify-center rounded-[14px] bg-rose-100 px-3.5 py-3 dark:bg-rose-500/18"
49 disabled={props.respondingApprovalId === props.approval.requestId}
50 onPress={() => void props.onRespond(props.approval.requestId, "decline")}
51 >
52 <Text className="font-t3-bold text-sm text-rose-700 dark:text-rose-300">Decline</Text>
53 </Pressable>
54 </View>
55 </View>
56 );
57}
apps/mobile/src/features/threads/PendingUserInputCard.tsx created+105
...@@ -0,0 +1,105 @@
1import type { ApprovalRequestId } from "@t3tools/contracts";
2import { Pressable, View } from "react-native";
3
4import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
5import { cn } from "../../lib/cn";
6import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity";
7
8export interface PendingUserInputCardProps {
9 readonly pendingUserInput: PendingUserInput;
10 readonly drafts: Record<string, PendingUserInputDraftAnswer>;
11 readonly answers: Record<string, string> | null;
12 readonly respondingUserInputId: ApprovalRequestId | null;
13 readonly onSelectOption: (
14 requestId: ApprovalRequestId,
15 questionId: string,
16 label: string,
17 ) => void;
18 readonly onChangeCustomAnswer: (
19 requestId: ApprovalRequestId,
20 questionId: string,
21 customAnswer: string,
22 ) => void;
23 readonly onSubmit: () => Promise<void>;
24}
25
26export function PendingUserInputCard(props: PendingUserInputCardProps) {
27 return (
28 <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80">
29 <Text className="font-t3-bold text-[11px] uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
30 User input needed
31 </Text>
32 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
33 Fill in the pending answers
34 </Text>
35 {props.pendingUserInput.questions.map((question) => {
36 const draft = props.drafts[question.id];
37 return (
38 <View key={question.id} className="gap-2 pt-1">
39 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
40 {question.header}
41 </Text>
42 <Text className="font-sans text-[15px] leading-[21px] text-neutral-950 dark:text-neutral-50">
43 {question.question}
44 </Text>
45 <View className="flex-row flex-wrap gap-2.5">
46 {question.options.map((option) => {
47 const selected =
48 draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length;
49 return (
50 <Pressable
51 key={option.label}
52 className={cn(
53 "rounded-full border px-3 py-2.5 ",
54 selected
55 ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"
56 : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70",
57 )}
58 onPress={() =>
59 props.onSelectOption(
60 props.pendingUserInput.requestId,
61 question.id,
62 option.label,
63 )
64 }
65 >
66 <Text
67 className={cn(
68 "font-t3-bold text-[13px]",
69 selected
70 ? "text-sky-700 dark:text-sky-300"
71 : "text-neutral-600 dark:text-neutral-300",
72 )}
73 >
74 {option.label}
75 </Text>
76 </Pressable>
77 );
78 })}
79 </View>
80 <TextInput
81 value={draft?.customAnswer ?? ""}
82 onChangeText={(value) =>
83 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
84 }
85 placeholder="Or type a custom answer"
86 className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-[15px] text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50"
87 />
88 </View>
89 );
90 })}
91 <Pressable
92 className={cn(
93 "items-center justify-center rounded-2xl px-4 py-3.5",
94 props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60",
95 )}
96 disabled={
97 props.answers === null || props.respondingUserInputId === props.pendingUserInput.requestId
98 }
99 onPress={() => void props.onSubmit()}
100 >
101 <Text className="font-t3-extrabold text-sm text-white">Submit answers</Text>
102 </Pressable>
103 </View>
104 );
105}
apps/mobile/src/features/threads/ThreadComposer.tsx created+821
...@@ -0,0 +1,821 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type {
3 EnvironmentId,
4 ModelSelection,
5 OrchestrationThread,
6 ProviderInteractionMode,
7 RuntimeMode,
8 ServerConfig as T3ServerConfig,
9} from "@t3tools/contracts";
10import {
11 detectComposerTrigger,
12 replaceTextRange,
13 type ComposerTrigger,
14} from "@t3tools/shared/composerTrigger";
15import { TextInputWrapper } from "expo-paste-input";
16import type { ReactNode } from "react";
17import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
18import {
19 Image,
20 Pressable,
21 TextInput as RNTextInput,
22 useColorScheme,
23 View,
24 type NativeSyntheticEvent,
25 type TextInputSelectionChangeEventData,
26 type ViewStyle,
27} from "react-native";
28import ImageViewing from "react-native-image-viewing";
29import { useThemeColor } from "../../lib/useThemeColor";
30
31import { AppText as Text } from "../../components/AppText";
32import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
33import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
34import { ProviderIcon } from "../../components/ProviderIcon";
35import type { DraftComposerImageAttachment } from "../../lib/composerImages";
36import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
37import type { RemoteClientConnectionState } from "../../lib/connection";
38import { useNativePaste } from "../../lib/useNativePaste";
39import {
40 insertRankedSearchResult,
41 normalizeSearchQuery,
42 scoreQueryMatch,
43} from "@t3tools/shared/searchRanking";
44import {
45 getModelSelectionBooleanOptionValue,
46 getModelSelectionStringOptionValue,
47} from "@t3tools/shared/model";
48import { useComposerPathSearch } from "../../state/use-composer-path-search";
49import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";
50import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
51
52/**
53 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
54 * Exported so the parent can compute feed overlap / content insets.
55 */
56export const COMPOSER_COLLAPSED_CHROME = 68;
57
58/**
59 * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset).
60 * Used by the parent to compute the larger feed bottom inset when the composer is focused.
61 */
62export const COMPOSER_EXPANDED_CHROME = 174;
63
64/**
65 * Height of the expanded-only toolbar below the text surface.
66 * Used by the feed inset because KeyboardAvoidingLegendList only accounts for
67 * keyboard height; the floating toolbar remains an additional overlay.
68 */
69export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 54;
70
71export interface ThreadComposerProps {
72 readonly draftMessage: string;
73 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
74 readonly placeholder: string;
75 readonly bottomInset?: number;
76 readonly connectionState: RemoteClientConnectionState;
77 readonly selectedThread: OrchestrationThread;
78 readonly serverConfig: T3ServerConfig | null;
79 readonly queueCount: number;
80 readonly activeThreadBusy: boolean;
81 readonly environmentId: EnvironmentId;
82 readonly projectCwd: string | null;
83 readonly onChangeDraftMessage: (value: string) => void;
84 readonly onPickDraftImages: () => Promise<void>;
85 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
86 readonly onRemoveDraftImage: (imageId: string) => void;
87 readonly onRefresh: () => Promise<void>;
88 readonly onStopThread: () => Promise<void>;
89 readonly onSendMessage: () => void;
90 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise<void>;
91 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>;
92 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise<void>;
93 readonly onExpandedChange?: (expanded: boolean) => void;
94}
95
96/**
97 * The pill / card container — renders as LiquidGlassView on supported
98 * iOS 26+ devices (progressive blur, native morph), opaque View otherwise.
99 */
100function ComposerSurface(props: {
101 readonly children: ReactNode;
102 readonly style: ViewStyle;
103 readonly isDarkMode: boolean;
104}) {
105 if (isLiquidGlassSupported) {
106 return (
107 <LiquidGlassView
108 effect="clear"
109 interactive
110 tintColor={props.isDarkMode ? "rgba(44,44,46,0.5)" : "rgba(255,255,255,0.45)"}
111 colorScheme={props.isDarkMode ? "dark" : "light"}
112 style={props.style}
113 >
114 {props.children}
115 </LiquidGlassView>
116 );
117 }
118
119 return (
120 <View
121 style={[
122 props.style,
123 {
124 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
125 borderWidth: 1,
126 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
127 },
128 ]}
129 >
130 {props.children}
131 </View>
132 );
133}
134
135function withModelSelectionOption(
136 selection: ModelSelection,
137 id: string,
138 value: string | boolean | undefined,
139): ModelSelection {
140 const options = (selection.options ?? []).filter((option) => option.id !== id);
141 return {
142 ...selection,
143 options: value === undefined ? options : [...options, { id, value }],
144 };
145}
146
147export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
148 const isDarkMode = useColorScheme() === "dark";
149 const themePlaceholderColor = useThemeColor("--color-placeholder");
150 const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor;
151 const foregroundColor = useThemeColor("--color-foreground");
152 const inputRef = useRef<RNTextInput>(null);
153 const [isFocused, setIsFocused] = useState(false);
154 const wasExpandedBeforePreviewRef = useRef(false);
155 const { onExpandedChange } = props;
156
157 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
158 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
159 const isExpanded = isFocused;
160 const canSend = props.connectionState === "ready" && hasContent;
161
162 const onPressImage = useCallback(
163 (uri: string) => {
164 wasExpandedBeforePreviewRef.current = isFocused;
165 setPreviewImageUri(uri);
166 },
167 [isFocused],
168 );
169
170 const closePreview = useCallback(() => {
171 setPreviewImageUri(null);
172 if (wasExpandedBeforePreviewRef.current) {
173 setTimeout(() => inputRef.current?.focus(), 100);
174 }
175 }, []);
176
177 useEffect(() => {
178 onExpandedChange?.(isExpanded);
179 }, [isExpanded, onExpandedChange]);
180 const showStopAction =
181 props.selectedThread.session?.status === "running" ||
182 props.selectedThread.session?.status === "starting" ||
183 props.queueCount > 0;
184
185 const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send";
186 const modelProvider = props.selectedThread.modelSelection?.instanceId ?? null;
187 const currentModelSelection = props.selectedThread.modelSelection;
188 const currentRuntimeMode = props.selectedThread.runtimeMode;
189 const currentInteractionMode = props.selectedThread.interactionMode ?? "default";
190 const selectedProviderStatus = useMemo(() => {
191 if (!props.serverConfig) return null;
192 return (
193 props.serverConfig.providers.find(
194 (p) => p.instanceId === props.selectedThread.modelSelection.instanceId,
195 ) ?? null
196 );
197 }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
198
199 // Extract current model options (effort, fastMode, contextWindow)
200 const selectedProviderDriver = selectedProviderStatus?.driver ?? null;
201 const currentEffort =
202 selectedProviderDriver === "claudeAgent"
203 ? (getModelSelectionStringOptionValue(currentModelSelection, "effort") ?? "high")
204 : "high";
205 const currentFastMode =
206 getModelSelectionBooleanOptionValue(currentModelSelection, "fastMode") ?? false;
207 const currentContextWindow =
208 selectedProviderDriver === "claudeAgent"
209 ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M")
210 : "1M";
211
212 const handleNativePaste = useNativePaste((uris) => {
213 void props.onNativePasteImages(uris);
214 });
215
216 // ── Trigger detection ────────────────────────────────────
217 const [cursorPosition, setCursorPosition] = useState(0);
218
219 const handleSelectionChange = useCallback(
220 (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
221 const { start } = event.nativeEvent.selection;
222 setCursorPosition(start);
223 },
224 [],
225 );
226
227 const composerTrigger = useMemo<ComposerTrigger | null>(
228 () => detectComposerTrigger(props.draftMessage, cursorPosition),
229 [cursorPosition, props.draftMessage],
230 );
231 const pathSearch = useComposerPathSearch({
232 environmentId: props.environmentId,
233 cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,
234 query: composerTrigger?.kind === "path" ? composerTrigger.query : null,
235 });
236
237 const composerMenuItems: ComposerCommandItem[] = useMemo(() => {
238 if (!composerTrigger) return [];
239
240 if (composerTrigger.kind === "slash-command") {
241 const q = composerTrigger.query.toLowerCase();
242 const allBuiltIn = [
243 {
244 id: "cmd:model",
245 type: "slash-command" as const,
246 command: "model",
247 label: "/model",
248 description: "Switch model",
249 },
250 {
251 id: "cmd:plan",
252 type: "slash-command" as const,
253 command: "plan",
254 label: "/plan",
255 description: "Switch to plan mode",
256 },
257 {
258 id: "cmd:default",
259 type: "slash-command" as const,
260 command: "default",
261 label: "/default",
262 description: "Switch to default mode",
263 },
264 ];
265 const builtIn = allBuiltIn.filter((item) => item.command.includes(q));
266
267 const providerCommands: ComposerCommandItem[] = [];
268 for (const cmd of selectedProviderStatus?.slashCommands ?? []) {
269 if (!cmd.name.toLowerCase().includes(q)) continue;
270 providerCommands.push({
271 id: `pcmd:${cmd.name}`,
272 type: "provider-slash-command" as const,
273 command: cmd,
274 label: `/${cmd.name}`,
275 description: cmd.description ?? "",
276 });
277 }
278
279 return [...builtIn, ...providerCommands];
280 }
281
282 if (composerTrigger.kind === "skill") {
283 const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
284 const normalizedQuery = normalizeSearchQuery(composerTrigger.query, {
285 trimLeadingPattern: /^\$+/,
286 });
287
288 if (!normalizedQuery) {
289 return enabledSkills.slice(0, 20).map((skill) => ({
290 id: `skill:${skill.name}`,
291 type: "skill" as const,
292 skill,
293 label: skill.displayName ?? skill.name,
294 description: skill.shortDescription ?? skill.description ?? "",
295 }));
296 }
297
298 const ranked: Array<{
299 item: (typeof enabledSkills)[number];
300 score: number;
301 tieBreaker: string;
302 }> = [];
303 for (const skill of enabledSkills) {
304 const displayLabel = (skill.displayName ?? skill.name).toLowerCase();
305 const scores = [
306 scoreQueryMatch({
307 value: skill.name.toLowerCase(),
308 query: normalizedQuery,
309 exactBase: 0,
310 prefixBase: 2,
311 boundaryBase: 4,
312 includesBase: 6,
313 fuzzyBase: 100,
314 boundaryMarkers: ["-", "_", "/"],
315 }),
316 scoreQueryMatch({
317 value: displayLabel,
318 query: normalizedQuery,
319 exactBase: 1,
320 prefixBase: 3,
321 boundaryBase: 5,
322 includesBase: 7,
323 fuzzyBase: 110,
324 }),
325 scoreQueryMatch({
326 value: skill.shortDescription?.toLowerCase() ?? "",
327 query: normalizedQuery,
328 exactBase: 20,
329 prefixBase: 22,
330 boundaryBase: 24,
331 includesBase: 26,
332 }),
333 scoreQueryMatch({
334 value: skill.description?.toLowerCase() ?? "",
335 query: normalizedQuery,
336 exactBase: 30,
337 prefixBase: 32,
338 boundaryBase: 34,
339 includesBase: 36,
340 }),
341 ].filter((s): s is number => s !== null);
342
343 if (scores.length > 0) {
344 insertRankedSearchResult(
345 ranked,
346 {
347 item: skill,
348 score: Math.min(...scores),
349 tieBreaker: `${displayLabel}\u0000${skill.name}`,
350 },
351 20,
352 );
353 }
354 }
355
356 return ranked.map(({ item: skill }) => ({
357 id: `skill:${skill.name}`,
358 type: "skill" as const,
359 skill,
360 label: skill.displayName ?? skill.name,
361 description: skill.shortDescription ?? skill.description ?? "",
362 }));
363 }
364
365 if (composerTrigger.kind === "path") {
366 return pathSearch.entries.map((entry) => {
367 const parts = entry.path.split("/");
368 return {
369 id: `path:${entry.path}`,
370 type: "path" as const,
371 path: entry.path,
372 kind: entry.kind,
373 label: parts[parts.length - 1] ?? entry.path,
374 description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
375 };
376 });
377 }
378
379 return [];
380 }, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
381
382 // ── Handle command selection ──────────────────────────────
383 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
384
385 const handleSend = useCallback(() => {
386 onSendMessage();
387 inputRef.current?.blur();
388 }, [onSendMessage]);
389 const handleCommandSelect = useCallback(
390 (item: ComposerCommandItem) => {
391 if (!composerTrigger) return;
392
393 if (
394 item.type === "slash-command" &&
395 (item.command === "plan" || item.command === "default")
396 ) {
397 const result = replaceTextRange(
398 draftMessage,
399 composerTrigger.rangeStart,
400 composerTrigger.rangeEnd,
401 "",
402 );
403 setCursorPosition(result.cursor);
404 onChangeDraftMessage(result.text);
405 void onUpdateInteractionMode(item.command);
406 return;
407 }
408
409 let replacement = "";
410 if (item.type === "path") {
411 replacement = `@${item.path} `;
412 } else if (item.type === "skill") {
413 replacement = `$${item.skill.name} `;
414 } else if (item.type === "slash-command") {
415 replacement = `/${item.command} `;
416 } else if (item.type === "provider-slash-command") {
417 replacement = `/${item.command.name} `;
418 }
419
420 const result = replaceTextRange(
421 draftMessage,
422 composerTrigger.rangeStart,
423 composerTrigger.rangeEnd,
424 replacement,
425 );
426 setCursorPosition(result.cursor);
427 onChangeDraftMessage(result.text);
428 },
429 [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode],
430 );
431
432 // ── Model menu ───────────────────────────────────────────
433 const providerGroups = useMemo(() => {
434 const options = buildModelOptions(props.serverConfig, currentModelSelection);
435 return groupByProvider(options);
436 }, [props.serverConfig, currentModelSelection]);
437
438 const modelMenuActions = useMemo(
439 () =>
440 providerGroups.map((group) => ({
441 id: `provider:${group.providerKey}`,
442 title: group.providerLabel,
443 subtitle: group.models.find(
444 (model) =>
445 model.selection.instanceId === currentModelSelection.instanceId &&
446 model.selection.model === currentModelSelection.model,
447 )?.label,
448 subactions: group.models.map((option) => ({
449 id: `model:${option.key}`,
450 title: option.label,
451 state:
452 option.selection.instanceId === currentModelSelection.instanceId &&
453 option.selection.model === currentModelSelection.model
454 ? ("on" as const)
455 : undefined,
456 })),
457 })),
458 [providerGroups, currentModelSelection],
459 );
460
461 // ── Options menu ─────────────────────────────────────────
462 const optionsMenuActions = useMemo(
463 () => [
464 {
465 id: "options-effort",
466 title: "Effort",
467 subtitle: `${currentEffort.charAt(0).toUpperCase()}${currentEffort.slice(1)}`,
468 subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({
469 id: `options:effort:${level}`,
470 title: `${level}${level === "high" ? " (default)" : ""}`,
471 state: currentEffort === level ? ("on" as const) : undefined,
472 })),
473 },
474 {
475 id: "options-fast-mode",
476 title: "Fast Mode",
477 subtitle: currentFastMode ? "On" : "Off",
478 subactions: ([false, true] as const).map((value) => ({
479 id: `options:fast-mode:${value ? "on" : "off"}`,
480 title: value ? "On" : "Off",
481 state: currentFastMode === value ? ("on" as const) : undefined,
482 })),
483 },
484 {
485 id: "options-context-window",
486 title: "Context Window",
487 subtitle: currentContextWindow,
488 subactions: (["200k", "1M"] as const).map((value) => ({
489 id: `options:context-window:${value}`,
490 title: `${value}${value === "1M" ? " (default)" : ""}`,
491 state: currentContextWindow === value ? ("on" as const) : undefined,
492 })),
493 },
494 {
495 id: "options-runtime",
496 title: "Runtime",
497 subtitle:
498 currentRuntimeMode === "approval-required"
499 ? "Approve actions"
500 : currentRuntimeMode === "auto-accept-edits"
501 ? "Auto-accept edits"
502 : "Full access",
503 subactions: [
504 { id: "options:runtime:approval-required", title: "Approve actions" },
505 { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" },
506 { id: "options:runtime:full-access", title: "Full access" },
507 ].map((option) => {
508 const value = option.id.replace("options:runtime:", "");
509 return {
510 id: option.id,
511 title: option.title,
512 state: currentRuntimeMode === value ? ("on" as const) : undefined,
513 };
514 }),
515 },
516 {
517 id: "options-interaction",
518 title: "Interaction",
519 subtitle: currentInteractionMode === "plan" ? "Plan" : "Default",
520 subactions: [
521 { id: "options:interaction:default", title: "Default" },
522 { id: "options:interaction:plan", title: "Plan" },
523 ].map((option) => {
524 const value = option.id.replace("options:interaction:", "");
525 return {
526 id: option.id,
527 title: option.title,
528 state: currentInteractionMode === value ? ("on" as const) : undefined,
529 };
530 }),
531 },
532 ],
533 [
534 currentEffort,
535 currentFastMode,
536 currentContextWindow,
537 currentRuntimeMode,
538 currentInteractionMode,
539 ],
540 );
541
542 // ── Menu handlers ────────────────────────────────────────
543 function handleModelMenuAction(event: string) {
544 if (!event.startsWith("model:")) {
545 return;
546 }
547 const modelKey = event.slice("model:".length);
548 const options = buildModelOptions(props.serverConfig, currentModelSelection);
549 const option = options.find((o) => o.key === modelKey);
550 if (option) {
551 void props.onUpdateModelSelection(option.selection);
552 }
553 }
554
555 function handleOptionsMenuAction(event: string) {
556 if (event.startsWith("options:effort:")) {
557 const effort = event.slice("options:effort:".length);
558 const updated: ModelSelection =
559 selectedProviderDriver === "claudeAgent"
560 ? withModelSelectionOption(
561 currentModelSelection,
562 "effort",
563 effort as typeof currentEffort,
564 )
565 : currentModelSelection;
566 void props.onUpdateModelSelection(updated);
567 return;
568 }
569 if (event.startsWith("options:fast-mode:")) {
570 const fastMode = event.endsWith(":on");
571 const nextFast = fastMode || undefined;
572 if (selectedProviderDriver === "opencode") {
573 return;
574 }
575 const updated = withModelSelectionOption(currentModelSelection, "fastMode", nextFast);
576 void props.onUpdateModelSelection(updated);
577 return;
578 }
579 if (event.startsWith("options:context-window:")) {
580 const contextWindow = event.slice("options:context-window:".length);
581 const updated: ModelSelection =
582 selectedProviderDriver === "claudeAgent"
583 ? withModelSelectionOption(currentModelSelection, "contextWindow", contextWindow)
584 : currentModelSelection;
585 void props.onUpdateModelSelection(updated);
586 return;
587 }
588 if (event.startsWith("options:runtime:")) {
589 const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode;
590 void props.onUpdateRuntimeMode(runtimeMode);
591 return;
592 }
593 if (event.startsWith("options:interaction:")) {
594 const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode;
595 void props.onUpdateInteractionMode(interactionMode);
596 }
597 }
598
599 return (
600 <View
601 style={{
602 paddingHorizontal: 16,
603 paddingTop: isExpanded ? 12 : 10,
604 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10),
605 experimental_backgroundImage: isDarkMode
606 ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)"
607 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
608 }}
609 >
610 <View className="w-full" style={{ position: "relative" }}>
611 {composerTrigger && composerMenuItems.length > 0 ? (
612 <View
613 style={{
614 position: "absolute",
615 bottom: "100%",
616 left: 0,
617 right: 0,
618 marginBottom: 8,
619 zIndex: 10,
620 }}
621 >
622 <ComposerCommandPopover
623 items={composerMenuItems}
624 triggerKind={composerTrigger.kind}
625 isLoading={pathSearch.isPending}
626 onSelect={handleCommandSelect}
627 />
628 </View>
629 ) : null}
630
631 <ComposerSurface
632 isDarkMode={isDarkMode}
633 style={
634 isExpanded
635 ? {
636 borderRadius: 20,
637 overflow: "hidden" as const,
638 paddingHorizontal: 14,
639 paddingVertical: 12,
640 }
641 : {
642 borderRadius: 999,
643 overflow: "hidden" as const,
644 flexDirection: "row" as const,
645 alignItems: "center" as const,
646 paddingLeft: 18,
647 paddingRight: 5,
648 paddingVertical: 5,
649 }
650 }
651 >
652 {/* Attachment strip — inside the card, above the text input */}
653 {isExpanded ? (
654 <View style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }}>
655 <ComposerAttachmentStrip
656 attachments={props.draftAttachments}
657 onRemove={props.onRemoveDraftImage}
658 onPressImage={onPressImage}
659 />
660 </View>
661 ) : null}
662
663 <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}>
664 <TextInputWrapper onPaste={handleNativePaste}>
665 <RNTextInput
666 ref={inputRef}
667 multiline
668 value={props.draftMessage}
669 onChangeText={props.onChangeDraftMessage}
670 onSelectionChange={handleSelectionChange}
671 placeholder={props.placeholder}
672 placeholderTextColor={placeholderColor}
673 editable={props.connectionState === "ready"}
674 onFocus={() => setIsFocused(true)}
675 onBlur={() => setIsFocused(false)}
676 textAlignVertical={isExpanded ? "top" : "center"}
677 style={
678 isExpanded
679 ? {
680 minHeight: 80,
681 maxHeight: 160,
682 paddingHorizontal: 4,
683 paddingVertical: 4,
684 fontSize: 15,
685 lineHeight: 22,
686 color: foregroundColor,
687 fontFamily: "DMSans_400Regular",
688 }
689 : {
690 maxHeight: 36,
691 paddingVertical: 6,
692 fontSize: 15,
693 lineHeight: 20,
694 color: foregroundColor,
695 fontFamily: "DMSans_400Regular",
696 }
697 }
698 />
699 </TextInputWrapper>
700 </View>
701 {!isExpanded && props.draftAttachments.length > 0 ? (
702 <View style={{ flexDirection: "row", gap: 4, paddingLeft: 4 }}>
703 {props.draftAttachments.slice(0, 3).map((image) => (
704 <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}>
705 <Image
706 source={{ uri: image.previewUri }}
707 className="bg-subtle"
708 style={{
709 width: 30,
710 height: 30,
711 borderRadius: 8,
712 }}
713 resizeMode="cover"
714 />
715 </Pressable>
716 ))}
717 {props.draftAttachments.length > 3 ? (
718 <View
719 className="bg-subtle-strong"
720 style={{
721 width: 30,
722 height: 30,
723 borderRadius: 8,
724 alignItems: "center",
725 justifyContent: "center",
726 }}
727 >
728 <Text className="text-foreground-muted text-[11px] font-t3-bold">
729 +{props.draftAttachments.length - 3}
730 </Text>
731 </View>
732 ) : null}
733 </View>
734 ) : null}
735 {!isExpanded ? (
736 showStopAction ? (
737 <ControlPill
738 icon="stop.fill"
739 variant="danger"
740 onPress={() => void props.onStopThread()}
741 />
742 ) : (
743 <ControlPill
744 icon="arrow.up"
745 variant="primary"
746 disabled={!canSend}
747 onPress={handleSend}
748 />
749 )
750 ) : null}
751 </ComposerSurface>
752
753 {/* Toolbar row — matches draft page layout (expanded only) */}
754 {isExpanded ? (
755 <View
756 style={{
757 flexDirection: "row",
758 alignItems: "center",
759 justifyContent: "space-between",
760 paddingTop: 10,
761 gap: 8,
762 }}
763 >
764 <ControlPill icon="plus" onPress={() => void props.onPickDraftImages()} />
765 <ControlPillMenu
766 actions={modelMenuActions}
767 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
768 >
769 <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} />
770 </ControlPillMenu>
771 <ControlPillMenu
772 actions={optionsMenuActions}
773 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
774 >
775 <ControlPill icon="slider.horizontal.3" />
776 </ControlPillMenu>
777 <ControlPill icon="arrow.clockwise" onPress={() => void props.onRefresh()} />
778 {showStopAction ? (
779 <ControlPill
780 icon="stop.fill"
781 variant="danger"
782 onPress={() => void props.onStopThread()}
783 />
784 ) : null}
785 <ControlPill
786 icon="arrow.up"
787 label={sendLabel}
788 variant="primary"
789 disabled={!canSend}
790 onPress={handleSend}
791 />
792 </View>
793 ) : null}
794
795 {/* Queue count */}
796 {props.queueCount > 0 ? (
797 <Text
798 className="text-foreground-muted"
799 style={{
800 fontSize: 12,
801 lineHeight: 18,
802 paddingTop: 8,
803 }}
804 >
805 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
806 automatically.
807 </Text>
808 ) : null}
809 </View>
810
811 <ImageViewing
812 images={previewImageUri ? [{ uri: previewImageUri }] : []}
813 imageIndex={0}
814 visible={previewImageUri !== null}
815 onRequestClose={closePreview}
816 swipeToCloseEnabled
817 doubleTapToZoomEnabled
818 />
819 </View>
820 );
821});
apps/mobile/src/features/threads/ThreadDetailScreen.tsx created+347
...@@ -0,0 +1,347 @@
1import type {
2 ApprovalRequestId,
3 EnvironmentId,
4 ModelSelection,
5 OrchestrationThread,
6 ProviderApprovalDecision,
7 ProviderInteractionMode,
8 RuntimeMode,
9 ServerConfig as T3ServerConfig,
10 ThreadId,
11} from "@t3tools/contracts";
12import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
13import * as Haptics from "expo-haptics";
14import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
15import { View, type LayoutChangeEvent } from "react-native";
16import { Gesture, GestureDetector } from "react-native-gesture-handler";
17import { KeyboardStickyView } from "react-native-keyboard-controller";
18import { useSafeAreaInsets } from "react-native-safe-area-context";
19import { runOnJS } from "react-native-reanimated";
20
21import { AppText as Text } from "../../components/AppText";
22import type { StatusTone } from "../../components/StatusPill";
23import type { DraftComposerImageAttachment } from "../../lib/composerImages";
24import type { MobileLayoutVariant } from "../../lib/mobileLayout";
25import type {
26 PendingApproval,
27 PendingUserInput,
28 PendingUserInputDraftAnswer,
29 ThreadFeedEntry,
30} from "../../lib/threadActivity";
31import { PendingApprovalCard } from "./PendingApprovalCard";
32import { PendingUserInputCard } from "./PendingUserInputCard";
33import {
34 COMPOSER_COLLAPSED_CHROME,
35 COMPOSER_EXPANDED_CHROME,
36 COMPOSER_EXPANDED_TOOLBAR_CHROME,
37 ThreadComposer,
38} from "./ThreadComposer";
39import { ThreadFeed } from "./ThreadFeed";
40
41export interface ThreadDetailScreenProps {
42 readonly selectedThread: OrchestrationThread;
43 readonly screenTone: StatusTone;
44 readonly connectionError: string | null;
45 readonly httpBaseUrl: string | null;
46 readonly bearerToken: string | null;
47 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
48 readonly activeWorkStartedAt: string | null;
49 readonly activePendingApproval: PendingApproval | null;
50 readonly respondingApprovalId: ApprovalRequestId | null;
51 readonly activePendingUserInput: PendingUserInput | null;
52 readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
53 readonly activePendingUserInputAnswers: Record<string, string> | null;
54 readonly respondingUserInputId: ApprovalRequestId | null;
55 readonly draftMessage: string;
56 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
57 readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
58 readonly activeThreadBusy: boolean;
59 readonly environmentId: EnvironmentId;
60 readonly projectWorkspaceRoot: string | null;
61 readonly selectedThreadQueueCount: number;
62 readonly serverConfig: T3ServerConfig | null;
63 readonly layoutVariant?: MobileLayoutVariant;
64 readonly onOpenDrawer: () => void;
65 readonly onOpenConnectionEditor: () => void;
66 readonly onChangeDraftMessage: (value: string) => void;
67 readonly onPickDraftImages: () => Promise<void>;
68 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
69 readonly onRemoveDraftImage: (imageId: string) => void;
70 readonly onRefresh: () => Promise<void>;
71 readonly onStopThread: () => Promise<void>;
72 readonly onSendMessage: () => void;
73 readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>;
74 readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>;
75 readonly onUpdateThreadInteractionMode: (
76 interactionMode: ProviderInteractionMode,
77 ) => Promise<void>;
78 readonly onRespondToApproval: (
79 requestId: ApprovalRequestId,
80 decision: ProviderApprovalDecision,
81 ) => Promise<void>;
82 readonly onSelectUserInputOption: (
83 requestId: ApprovalRequestId,
84 questionId: string,
85 label: string,
86 ) => void;
87 readonly onChangeUserInputCustomAnswer: (
88 requestId: ApprovalRequestId,
89 questionId: string,
90 customAnswer: string,
91 ) => void;
92 readonly onSubmitUserInput: () => Promise<void>;
93 readonly showContent?: boolean;
94}
95
96function latestStreamingAssistantMessage(
97 feed: ReadonlyArray<ThreadFeedEntry>,
98): { readonly id: string; readonly textLength: number } | null {
99 for (let index = feed.length - 1; index >= 0; index -= 1) {
100 const entry = feed[index];
101 if (entry?.type !== "message") {
102 continue;
103 }
104 if (entry.message.role !== "assistant" || !entry.message.streaming) {
105 continue;
106 }
107 return {
108 id: entry.message.id,
109 textLength: entry.message.text.length,
110 };
111 }
112
113 return null;
114}
115
116function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedEntry>) {
117 const lastStreamingAssistantRef = useRef<{
118 readonly id: string;
119 readonly textLength: number;
120 } | null>(null);
121 const lastStreamHapticAtRef = useRef(0);
122 const hydratedRef = useRef(false);
123 const previousThreadIdRef = useRef(threadId);
124
125 useEffect(() => {
126 if (previousThreadIdRef.current !== threadId) {
127 previousThreadIdRef.current = threadId;
128 hydratedRef.current = false;
129 }
130
131 const latestStreamingMessage = latestStreamingAssistantMessage(feed);
132
133 if (!hydratedRef.current) {
134 hydratedRef.current = true;
135 lastStreamingAssistantRef.current = latestStreamingMessage;
136 return;
137 }
138
139 if (!latestStreamingMessage) {
140 lastStreamingAssistantRef.current = null;
141 return;
142 }
143
144 const previousStreamingMessage = lastStreamingAssistantRef.current;
145 lastStreamingAssistantRef.current = latestStreamingMessage;
146
147 const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id;
148 const textGrew =
149 previousStreamingMessage?.id === latestStreamingMessage.id &&
150 latestStreamingMessage.textLength > previousStreamingMessage.textLength;
151
152 if (!isNewStream && !textGrew) {
153 return;
154 }
155
156 const now = Date.now();
157 if (!isNewStream && now - lastStreamHapticAtRef.current < 320) {
158 return;
159 }
160
161 lastStreamHapticAtRef.current = now;
162 void Haptics.selectionAsync();
163 }, [threadId, feed]);
164}
165
166const WORKING_INDICATOR_HEIGHT = 44;
167
168const WorkingDurationPill = memo(function WorkingDurationPill(props: {
169 readonly startedAt: string;
170}) {
171 const [nowMs, setNowMs] = useState(() => Date.now());
172
173 useEffect(() => {
174 const intervalId = setInterval(() => {
175 setNowMs(Date.now());
176 }, 1_000);
177 return () => clearInterval(intervalId);
178 }, [props.startedAt]);
179
180 const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
181
182 return (
183 <View className="px-4 pb-2" style={{ flexShrink: 0 }}>
184 <View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]">
185 <View className="flex-row items-center gap-2">
186 <View className="flex-row items-center gap-1">
187 <View className="h-1.5 w-1.5 rounded-full bg-neutral-400 dark:bg-neutral-500" />
188 <View className="h-1.5 w-1.5 rounded-full bg-neutral-400/80 dark:bg-neutral-500/80" />
189 <View className="h-1.5 w-1.5 rounded-full bg-neutral-400/60 dark:bg-neutral-500/60" />
190 </View>
191 <Text className="font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
192 Working for {durationLabel}
193 </Text>
194 </View>
195 </View>
196 </View>
197 );
198});
199
200export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
201 const { onOpenDrawer, onRefresh } = props;
202
203 const insets = useSafeAreaInsets();
204 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
205 const composerBottomInset = Math.max(insets.bottom, 12);
206 const [composerExpanded, setComposerExpanded] = useState(false);
207 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
208 const composerOverlapHeight = composerChrome + composerBottomInset;
209 const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
210 const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
211 const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0);
212 const [refreshing, setRefreshing] = useState(false);
213 const showContent = props.showContent ?? true;
214 const layoutVariant = props.layoutVariant ?? "compact";
215 const isSplitLayout = layoutVariant === "split";
216 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
217 const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0;
218 const feedBottomInset =
219 Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8;
220
221 const completeDrawerGesture = useCallback(() => {
222 void Haptics.selectionAsync();
223 onOpenDrawer();
224 }, [onOpenDrawer]);
225
226 const handleRefresh = useCallback(async (): Promise<void> => {
227 if (refreshing) {
228 return;
229 }
230
231 setRefreshing(true);
232 try {
233 await onRefresh();
234 } finally {
235 setRefreshing(false);
236 }
237 }, [onRefresh, refreshing]);
238
239 const drawerGestureThreshold = 80;
240 const headerDrawerGesture = useMemo(
241 () =>
242 Gesture.Pan()
243 .enabled(!isSplitLayout)
244 .hitSlop({ left: 0, width: 40 })
245 .activeOffsetX([10, 999])
246 .failOffsetY([-24, 24])
247 .onEnd((event) => {
248 const translationX = Math.max(event.translationX, 0);
249 if (event.y < drawerGestureThreshold && translationX > 56) {
250 runOnJS(completeDrawerGesture)();
251 }
252 }),
253 [completeDrawerGesture, isSplitLayout],
254 );
255
256 const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => {
257 const nextHeight = Math.ceil(event.nativeEvent.layout.height);
258 setMeasuredOverlayHeight((current) =>
259 Math.abs(current - nextHeight) > 1 ? nextHeight : current,
260 );
261 }, []);
262
263 return (
264 <GestureDetector gesture={headerDrawerGesture}>
265 <View className="flex-1">
266 {showContent ? (
267 <ThreadFeed
268 threadId={props.selectedThread.id}
269 feed={props.selectedThreadFeed}
270 httpBaseUrl={props.httpBaseUrl}
271 bearerToken={props.bearerToken}
272 agentLabel={agentLabel}
273 contentBottomInset={feedBottomInset}
274 layoutVariant={layoutVariant}
275 composerExpanded={composerExpanded}
276 refreshing={refreshing}
277 onRefresh={handleRefresh}
278 />
279 ) : (
280 <View style={{ flex: 1 }} />
281 )}
282
283 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
284 {showContent ? (
285 <KeyboardStickyView
286 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
287 offset={{ closed: 0, opened: 0 }}
288 >
289 <View onLayout={handleOverlayLayout}>
290 {props.activeWorkStartedAt ? (
291 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
292 ) : null}
293
294 {props.activePendingApproval || props.activePendingUserInput ? (
295 <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
296 {props.activePendingApproval ? (
297 <PendingApprovalCard
298 approval={props.activePendingApproval}
299 respondingApprovalId={props.respondingApprovalId}
300 onRespond={props.onRespondToApproval}
301 />
302 ) : null}
303 {props.activePendingUserInput ? (
304 <PendingUserInputCard
305 pendingUserInput={props.activePendingUserInput}
306 drafts={props.activePendingUserInputDrafts}
307 answers={props.activePendingUserInputAnswers}
308 respondingUserInputId={props.respondingUserInputId}
309 onSelectOption={props.onSelectUserInputOption}
310 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
311 onSubmit={props.onSubmitUserInput}
312 />
313 ) : null}
314 </View>
315 ) : null}
316
317 <ThreadComposer
318 draftMessage={props.draftMessage}
319 draftAttachments={props.draftAttachments}
320 placeholder="Ask the repo agent, or run a command…"
321 connectionState={props.connectionStateLabel}
322 selectedThread={props.selectedThread}
323 serverConfig={props.serverConfig}
324 queueCount={props.selectedThreadQueueCount}
325 activeThreadBusy={props.activeThreadBusy}
326 environmentId={props.environmentId}
327 projectCwd={props.projectWorkspaceRoot}
328 bottomInset={composerBottomInset}
329 onChangeDraftMessage={props.onChangeDraftMessage}
330 onPickDraftImages={props.onPickDraftImages}
331 onNativePasteImages={props.onNativePasteImages}
332 onRemoveDraftImage={props.onRemoveDraftImage}
333 onRefresh={props.onRefresh}
334 onStopThread={props.onStopThread}
335 onSendMessage={props.onSendMessage}
336 onUpdateModelSelection={props.onUpdateThreadModelSelection}
337 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
338 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
339 onExpandedChange={setComposerExpanded}
340 />
341 </View>
342 </KeyboardStickyView>
343 ) : null}
344 </View>
345 </GestureDetector>
346 );
347});
apps/mobile/src/features/threads/ThreadFeed.tsx created+989
...@@ -0,0 +1,989 @@
1import * as Clipboard from "expo-clipboard";
2import * as Haptics from "expo-haptics";
3import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";
4import { type LegendListRef } from "@legendapp/list/react-native";
5import type { ThreadId } from "@t3tools/contracts";
6import { SymbolView } from "expo-symbols";
7import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
8import {
9 Markdown,
10 type CustomRenderers,
11 type NodeStyleOverrides,
12 type PartialMarkdownTheme,
13} from "react-native-nitro-markdown";
14import {
15 Image,
16 Pressable,
17 ScrollView,
18 StyleSheet,
19 Text as NativeText,
20 type ColorValue,
21 useColorScheme,
22 useWindowDimensions,
23 View,
24} from "react-native";
25import { TouchableOpacity } from "react-native-gesture-handler";
26import ImageViewing from "react-native-image-viewing";
27import { useSafeAreaInsets } from "react-native-safe-area-context";
28import { useThemeColor } from "../../lib/useThemeColor";
29
30import { AppText as Text } from "../../components/AppText";
31import { EmptyState } from "../../components/EmptyState";
32import {
33 parseReviewCommentMessageSegments,
34 type ReviewInlineComment,
35} from "../review/reviewCommentSelection";
36import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
37import {
38 buildNativeReviewDiffData,
39 createNativeReviewDiffTheme,
40 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
41 NATIVE_REVIEW_DIFF_ROW_HEIGHT,
42 NATIVE_REVIEW_DIFF_STYLE,
43} from "../review/nativeReviewDiffAdapter";
44import { buildReviewParsedDiff } from "../review/reviewModel";
45import { cn } from "../../lib/cn";
46import type { MobileLayoutVariant } from "../../lib/mobileLayout";
47import type { ThreadFeedEntry } from "../../lib/threadActivity";
48import { relativeTime } from "../../lib/time";
49import { messageImageUrl } from "./threadPresentation";
50
51export interface ThreadFeedProps {
52 readonly threadId: ThreadId;
53 readonly feed: ReadonlyArray<ThreadFeedEntry>;
54 readonly httpBaseUrl: string | null;
55 readonly bearerToken: string | null;
56 readonly agentLabel: string;
57 readonly contentBottomInset?: number;
58 readonly refreshing?: boolean;
59 readonly onRefresh?: () => void;
60 readonly layoutVariant?: MobileLayoutVariant;
61 readonly composerExpanded?: boolean;
62}
63
64function stripShellWrapper(value: string): string {
65 const trimmed = value.trim();
66 const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/);
67 return (match?.[1] ?? trimmed).trim();
68}
69
70function compactActivityDetail(detail: string | null): string | null {
71 if (!detail) {
72 return null;
73 }
74
75 const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim();
76 return cleaned.length > 0 ? cleaned : null;
77}
78
79function buildActivityRows(
80 activities: ReadonlyArray<{
81 readonly id: string;
82 readonly createdAt: string;
83 readonly summary: string;
84 readonly detail: string | null;
85 readonly status: string | null;
86 }>,
87) {
88 return activities.map<{
89 id: string;
90 createdAt: string;
91 summary: string;
92 detail: string | null;
93 status: string | null;
94 }>((activity) => ({
95 id: activity.id,
96 createdAt: activity.createdAt,
97 summary: activity.summary,
98 detail: compactActivityDetail(activity.detail),
99 status: activity.status,
100 }));
101}
102
103const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
104
105function toMarkdownThemeColor(value: ColorValue): string {
106 return value as string;
107}
108
109interface MarkdownStyleSets {
110 readonly user: MarkdownStyleSet;
111 readonly assistant: MarkdownStyleSet;
112}
113
114interface MarkdownStyleSet {
115 readonly theme: PartialMarkdownTheme;
116 readonly styles: NodeStyleOverrides;
117 readonly renderers: CustomRenderers;
118}
119
120interface ReviewCommentColors {
121 readonly background: ColorValue;
122 readonly border: ColorValue;
123 readonly mutedBackground: ColorValue;
124 readonly text: ColorValue;
125 readonly mutedText: ColorValue;
126 readonly codeBackground: ColorValue;
127}
128
129function useReviewCommentColors(): ReviewCommentColors {
130 const colorScheme = useColorScheme();
131 const isDark = colorScheme === "dark";
132 const background = isDark ? "#151515" : "#ffffff";
133 const border = isDark ? "#2a2a2a" : "#d7d7d7";
134 const mutedBackground = isDark ? "#242424" : "#f2f2f2";
135 const text = isDark ? "#f3f3f3" : "#111111";
136 const mutedText = isDark ? "#8f8f8f" : "#666666";
137 const codeBackground = isDark ? "#0f0f0f" : "#ffffff";
138
139 return useMemo(
140 () => ({
141 background,
142 border,
143 mutedBackground,
144 text,
145 mutedText,
146 codeBackground,
147 }),
148 [background, border, codeBackground, mutedBackground, mutedText, text],
149 );
150}
151
152function useMarkdownStyles(): MarkdownStyleSets {
153 const bodyColor = useThemeColor("--color-md-body");
154 const strongColor = useThemeColor("--color-md-strong");
155 const linkColor = useThemeColor("--color-md-link");
156 const blockquoteBg = useThemeColor("--color-md-blockquote-bg");
157 const blockquoteBorder = useThemeColor("--color-md-blockquote-border");
158 const codeBg = useThemeColor("--color-md-code-bg");
159 const codeText = useThemeColor("--color-md-code-text");
160 const hrColor = useThemeColor("--color-md-hr");
161 const userBodyColor = useThemeColor("--color-user-bubble-foreground");
162 const userCodeBg = useThemeColor("--color-md-user-code-bg");
163 const userCodeText = useThemeColor("--color-md-user-code-text");
164 const userFenceBg = useThemeColor("--color-md-user-fence-bg");
165 const userFenceText = useThemeColor("--color-md-user-fence-text");
166
167 return useMemo(() => {
168 const markdownBodyColor = toMarkdownThemeColor(bodyColor);
169 const markdownStrongColor = toMarkdownThemeColor(strongColor);
170 const markdownLinkColor = toMarkdownThemeColor(linkColor);
171 const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg);
172 const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder);
173 const markdownCodeBg = toMarkdownThemeColor(codeBg);
174 const markdownCodeText = toMarkdownThemeColor(codeText);
175 const markdownHrColor = toMarkdownThemeColor(hrColor);
176 const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor);
177 const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg);
178 const markdownUserCodeText = toMarkdownThemeColor(userCodeText);
179 const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg);
180 const markdownUserFenceText = toMarkdownThemeColor(userFenceText);
181
182 const baseTheme: PartialMarkdownTheme = {
183 colors: {
184 text: markdownBodyColor,
185 heading: markdownStrongColor,
186 link: markdownLinkColor,
187 blockquote: markdownBlockquoteBorder,
188 border: markdownHrColor,
189 surfaceLight: markdownBlockquoteBg,
190 accent: markdownLinkColor,
191 tableBorder: markdownHrColor,
192 tableHeader: markdownBlockquoteBg,
193 tableHeaderText: markdownStrongColor,
194 tableRowOdd: "transparent",
195 tableRowEven: "transparent",
196 },
197 spacing: {
198 xs: 4,
199 s: 4,
200 m: 8,
201 l: 8,
202 xl: 16,
203 },
204 fontSizes: {
205 s: 13,
206 m: 15,
207 h1: 22,
208 h2: 19,
209 h3: 17,
210 h4: 15,
211 h5: 15,
212 h6: 15,
213 },
214 fontFamilies: {
215 regular: "DMSans_400Regular",
216 heading: "DMSans_700Bold",
217 mono: "ui-monospace",
218 },
219 headingWeight: "700",
220 borderRadius: {
221 s: 4,
222 m: 8,
223 l: 12,
224 },
225 showCodeLanguage: false,
226 };
227
228 const baseStyles: NodeStyleOverrides = {
229 document: { flexShrink: 1 },
230 paragraph: { marginTop: 0, marginBottom: 8 },
231 list: { marginTop: 4, marginBottom: 4 },
232 list_item: { marginTop: 0, marginBottom: 4 },
233 task_list_item: { marginTop: 0, marginBottom: 4 },
234 text: { lineHeight: 22 },
235 bold: {
236 fontWeight: "700",
237 color: markdownStrongColor,
238 fontFamily: "DMSans_700Bold",
239 },
240 italic: { fontStyle: "italic" },
241 link: {
242 color: markdownLinkColor,
243 textDecorationLine: "underline" as const,
244 },
245 blockquote: {
246 borderLeftWidth: 3,
247 borderLeftColor: markdownBlockquoteBorder,
248 backgroundColor: markdownBlockquoteBg,
249 paddingLeft: 12,
250 paddingVertical: 6,
251 marginLeft: 0,
252 marginVertical: 4,
253 borderRadius: 4,
254 },
255 heading: {
256 fontFamily: "DMSans_700Bold",
257 color: markdownStrongColor,
258 marginTop: 12,
259 marginBottom: 6,
260 },
261 horizontal_rule: {
262 backgroundColor: markdownHrColor,
263 height: 1,
264 marginVertical: 12,
265 },
266 };
267
268 const createCodeRenderers = (
269 inlineBackgroundColor: string,
270 inlineTextColor: string,
271 blockBackgroundColor: string,
272 blockTextColor: string,
273 ): CustomRenderers => ({
274 code_inline: ({ content }) => (
275 <NativeText
276 style={{
277 backgroundColor: inlineBackgroundColor,
278 color: inlineTextColor,
279 borderRadius: 5,
280 paddingHorizontal: 5,
281 paddingVertical: 1,
282 fontFamily: "ui-monospace",
283 fontSize: 13,
284 }}
285 >
286 {content}
287 </NativeText>
288 ),
289 code_block: ({ content }) => (
290 <View
291 style={{
292 backgroundColor: blockBackgroundColor,
293 borderRadius: 12,
294 padding: 12,
295 marginVertical: 8,
296 }}
297 >
298 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
299 <NativeText
300 selectable
301 style={{
302 color: blockTextColor,
303 fontFamily: "ui-monospace",
304 fontSize: 13,
305 lineHeight: 19,
306 }}
307 >
308 {content}
309 </NativeText>
310 </ScrollView>
311 </View>
312 ),
313 });
314
315 const userTheme: PartialMarkdownTheme = {
316 ...baseTheme,
317 colors: {
318 ...baseTheme.colors,
319 text: markdownUserBodyColor,
320 heading: markdownUserBodyColor,
321 link: markdownUserBodyColor,
322 code: markdownUserCodeText,
323 codeBackground: markdownUserCodeBg,
324 border: markdownUserFenceBg,
325 },
326 };
327 const userStyles: NodeStyleOverrides = {
328 ...baseStyles,
329 paragraph: { marginTop: 0, marginBottom: 0 },
330 bold: {
331 fontWeight: "700",
332 color: markdownUserBodyColor,
333 fontFamily: "DMSans_700Bold",
334 },
335 heading: {
336 ...baseStyles.heading,
337 color: markdownUserBodyColor,
338 },
339 link: {
340 color: markdownUserBodyColor,
341 textDecorationLine: "underline" as const,
342 },
343 };
344
345 const assistantTheme: PartialMarkdownTheme = {
346 ...baseTheme,
347 colors: {
348 ...baseTheme.colors,
349 code: markdownCodeText,
350 codeBackground: markdownCodeBg,
351 border: markdownCodeBg,
352 },
353 };
354 const assistantStyles: NodeStyleOverrides = {
355 ...baseStyles,
356 };
357
358 return {
359 user: {
360 theme: userTheme,
361 styles: userStyles,
362 renderers: createCodeRenderers(
363 markdownUserCodeBg,
364 markdownUserCodeText,
365 markdownUserFenceBg,
366 markdownUserFenceText,
367 ),
368 },
369 assistant: {
370 theme: assistantTheme,
371 styles: assistantStyles,
372 renderers: createCodeRenderers(
373 markdownCodeBg,
374 markdownCodeText,
375 markdownCodeBg,
376 markdownCodeText,
377 ),
378 },
379 };
380 }, [
381 blockquoteBg,
382 blockquoteBorder,
383 bodyColor,
384 codeBg,
385 codeText,
386 hrColor,
387 linkColor,
388 strongColor,
389 userBodyColor,
390 userCodeBg,
391 userCodeText,
392 userFenceBg,
393 userFenceText,
394 ]);
395}
396
397function renderFeedEntry(
398 info: { item: ThreadFeedEntry; index: number },
399 props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl"> & {
400 readonly copiedRowId: string | null;
401 readonly expandedWorkGroups: Record<string, boolean>;
402 readonly onCopyWorkRow: (rowId: string, value: string) => void;
403 readonly onToggleWorkGroup: (groupId: string) => void;
404 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
405 readonly iconSubtleColor: string | import("react-native").ColorValue;
406 readonly userBubbleColor: string | import("react-native").ColorValue;
407 readonly markdownStyles: MarkdownStyleSets;
408 readonly reviewCommentColors: ReviewCommentColors;
409 readonly reviewCommentBubbleWidth: number;
410 },
411) {
412 const entry = info.item;
413 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
414
415 if (entry.type === "message") {
416 const { message } = entry;
417 const isUser = message.role === "user";
418 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
419 const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`;
420 const attachments = message.attachments ?? [];
421 const hasReviewCommentContext = message.text.includes("<review_comment");
422
423 if (isUser) {
424 return (
425 <View className="mb-5 items-end">
426 <View
427 className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5"
428 style={{
429 backgroundColor: userBubbleColor,
430 ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),
431 }}
432 >
433 {message.text.trim().length > 0 ? (
434 <UserMessageContent
435 text={message.text}
436 markdownStyles={styles}
437 reviewCommentColors={props.reviewCommentColors}
438 />
439 ) : null}
440 {attachments.map((attachment) => {
441 const uri = messageImageUrl(props.httpBaseUrl, attachment.id);
442 if (!uri) {
443 return null;
444 }
445 const headers = props.bearerToken
446 ? { Authorization: `Bearer ${props.bearerToken}` }
447 : undefined;
448
449 return (
450 <TouchableOpacity
451 key={attachment.id}
452 activeOpacity={0.7}
453 onPress={() => props.onPressImage(uri, headers)}
454 >
455 <Image
456 source={{ uri, ...(headers ? { headers } : {}) }}
457 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
458 resizeMode="cover"
459 />
460 </TouchableOpacity>
461 );
462 })}
463 </View>
464 <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
465 {timestampLabel}
466 </Text>
467 </View>
468 );
469 }
470
471 // Skip empty assistant messages (no text, no attachments) — they would
472 // render as an orphaned timestamp and break adjacent activity-group merging.
473 if (message.text.trim().length === 0 && attachments.length === 0) {
474 return null;
475 }
476
477 return (
478 <View className="mb-5 px-1">
479 {message.text.trim().length > 0 ? (
480 <Markdown
481 options={{ gfm: true }}
482 renderers={styles.renderers}
483 styles={styles.styles}
484 theme={styles.theme}
485 >
486 {message.text}
487 </Markdown>
488 ) : null}
489 {attachments.map((attachment) => {
490 const uri = messageImageUrl(props.httpBaseUrl, attachment.id);
491 if (!uri) {
492 return null;
493 }
494 const headers = props.bearerToken
495 ? { Authorization: `Bearer ${props.bearerToken}` }
496 : undefined;
497
498 return (
499 <TouchableOpacity
500 key={attachment.id}
501 activeOpacity={0.7}
502 className="mt-1.5"
503 onPress={() => props.onPressImage(uri, headers)}
504 >
505 <Image
506 source={{ uri, ...(headers ? { headers } : {}) }}
507 className="aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800"
508 resizeMode="cover"
509 />
510 </TouchableOpacity>
511 );
512 })}
513 <Text className="mt-1.5 font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
514 {timestampLabel}
515 </Text>
516 </View>
517 );
518 }
519
520 if (entry.type === "queued-message") {
521 return (
522 <View className="mb-5 items-end">
523 <View
524 className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5 opacity-60"
525 style={{ backgroundColor: userBubbleColor }}
526 >
527 <Text className="font-sans text-[15px] leading-[22px] text-white">
528 {entry.queuedMessage.text}
529 </Text>
530 {entry.queuedMessage.attachments.length > 0 ? (
531 <Text className="font-t3-medium text-xs text-white/75">
532 {entry.queuedMessage.attachments.length} image
533 {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached
534 </Text>
535 ) : null}
536 </View>
537 <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
538 {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`}
539 </Text>
540 </View>
541 );
542 }
543
544 const rows = buildActivityRows(entry.activities);
545 const isExpanded = props.expandedWorkGroups[entry.id] ?? false;
546 const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
547 const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows;
548 const hiddenCount = rows.length - visibleRows.length;
549 const showHeader = hasOverflow;
550
551 return (
552 <View className="mb-3 rounded-[20px] border border-neutral-200/80 bg-neutral-50/85 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.025]">
553 {showHeader ? (
554 <View className="mb-1.5 flex-row items-center justify-between gap-3 px-0.5">
555 <Text className="font-t3-bold text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500">
556 Tool calls ({rows.length})
557 </Text>
558 <Pressable onPress={() => props.onToggleWorkGroup(entry.id)}>
559 <Text className="font-t3-medium text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500">
560 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
561 </Text>
562 </Pressable>
563 </View>
564 ) : null}
565 {visibleRows.map((row, index) => (
566 <View
567 key={row.id}
568 className={cn(
569 "flex-row items-center gap-2 rounded-lg px-1 py-1",
570 index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]",
571 )}
572 >
573 <View className="items-center justify-center pt-0.5">
574 <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" />
575 </View>
576 <ScrollView
577 horizontal
578 nestedScrollEnabled
579 directionalLockEnabled
580 showsHorizontalScrollIndicator={false}
581 bounces={false}
582 className="flex-1"
583 contentContainerStyle={{ paddingRight: 12 }}
584 style={{ flex: 1 }}
585 >
586 <Text
587 className="text-[12px] leading-[18px] text-neutral-600 dark:text-neutral-400"
588 onLongPress={() => {
589 const copyValue = row.detail ?? row.summary;
590 props.onCopyWorkRow(row.id, copyValue);
591 }}
592 style={{
593 fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace",
594 }}
595 >
596 {row.detail ? `${row.summary} - ${row.detail}` : row.summary}
597 </Text>
598 </ScrollView>
599 {props.copiedRowId === row.id ? (
600 <Text className="shrink-0 font-t3-medium text-[10px] uppercase tracking-[0.8px] text-emerald-600 dark:text-emerald-400">
601 Copied
602 </Text>
603 ) : null}
604 </View>
605 ))}
606 </View>
607 );
608}
609
610function UserMessageContent(props: {
611 readonly text: string;
612 readonly markdownStyles: MarkdownStyleSet;
613 readonly reviewCommentColors: ReviewCommentColors;
614}) {
615 const segments = parseReviewCommentMessageSegments(props.text);
616 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
617 if (!hasReviewComment) {
618 return (
619 <Markdown
620 options={{ gfm: true }}
621 renderers={props.markdownStyles.renderers}
622 styles={props.markdownStyles.styles}
623 theme={props.markdownStyles.theme}
624 >
625 {props.text}
626 </Markdown>
627 );
628 }
629
630 return (
631 <View className="w-full gap-2">
632 {segments.map((segment) => {
633 if (segment.kind === "review-comment") {
634 return (
635 <ReviewCommentCard
636 key={segment.comment.id}
637 comment={segment.comment}
638 colors={props.reviewCommentColors}
639 />
640 );
641 }
642
643 const text = segment.text.trim();
644 if (text.length === 0) {
645 return null;
646 }
647
648 return (
649 <Markdown
650 key={segment.id}
651 options={{ gfm: true }}
652 renderers={props.markdownStyles.renderers}
653 styles={props.markdownStyles.styles}
654 theme={props.markdownStyles.theme}
655 >
656 {text}
657 </Markdown>
658 );
659 })}
660 </View>
661 );
662}
663
664const ReviewCommentCard = memo(function ReviewCommentCard(props: {
665 readonly comment: ReviewInlineComment;
666 readonly colors: ReviewCommentColors;
667}) {
668 const colorScheme = useColorScheme();
669 const appearanceScheme = colorScheme === "light" ? "light" : "dark";
670 const NativeReviewDiffView = resolveNativeReviewDiffView();
671 const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]);
672 const parsedDiff = useMemo(
673 () => buildReviewParsedDiff(patch, `thread-review-comment:${props.comment.id}`),
674 [patch, props.comment.id],
675 );
676 const nativeReviewDiffData = useMemo(() => buildNativeReviewDiffData(parsedDiff), [parsedDiff]);
677 const compactNativeRows = useMemo(
678 () => nativeReviewDiffData.rows.filter((row) => row.kind !== "file"),
679 [nativeReviewDiffData.rows],
680 );
681 const nativeReviewDiffTheme = useMemo(
682 () => createNativeReviewDiffTheme(appearanceScheme),
683 [appearanceScheme],
684 );
685 const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]);
686 const nativeThemeJson = useMemo(
687 () => JSON.stringify(nativeReviewDiffTheme),
688 [nativeReviewDiffTheme],
689 );
690 const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);
691 const nativeDiffHeight = useMemo(
692 () =>
693 Math.min(
694 360,
695 Math.max(
696 112,
697 compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT +
698 NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin,
699 ),
700 ),
701 [compactNativeRows.length],
702 );
703 const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0;
704
705 return (
706 <View
707 className="w-full overflow-hidden rounded-[16px] border"
708 style={{
709 backgroundColor: props.colors.background,
710 borderColor: props.colors.border,
711 borderCurve: "continuous",
712 }}
713 >
714 <View
715 className="flex-row items-center gap-2 border-b px-3 py-2"
716 style={{ borderColor: props.colors.border }}
717 >
718 <View
719 className="size-6 items-center justify-center rounded-[7px]"
720 style={{ backgroundColor: props.colors.mutedBackground, borderCurve: "continuous" }}
721 >
722 <SymbolView
723 name="doc.text"
724 size={13}
725 tintColor={props.colors.mutedText}
726 type="monochrome"
727 />
728 </View>
729 <View className="min-w-0 flex-1">
730 <Text
731 className="font-mono text-[12px] leading-[16px]"
732 numberOfLines={1}
733 style={{ color: props.colors.text }}
734 >
735 {compactFileName(props.comment.filePath)}
736 </Text>
737 </View>
738 </View>
739 {shouldRenderNativeDiff ? (
740 <View
741 className="border-t"
742 collapsable={false}
743 style={{
744 backgroundColor: nativeReviewDiffTheme.background,
745 borderColor: props.colors.border,
746 height: nativeDiffHeight,
747 }}
748 >
749 <NativeReviewDiffView
750 collapsable={false}
751 style={StyleSheet.absoluteFill}
752 appearanceScheme={appearanceScheme}
753 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
754 rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}
755 rowsJson={nativeRowsJson}
756 styleJson={nativeStyleJson}
757 themeJson={nativeThemeJson}
758 />
759 </View>
760 ) : props.comment.diff.trim().length > 0 ? (
761 <ScrollView
762 horizontal
763 nestedScrollEnabled
764 directionalLockEnabled
765 showsHorizontalScrollIndicator={false}
766 bounces={false}
767 className="border-t"
768 style={{ backgroundColor: props.colors.codeBackground, borderColor: props.colors.border }}
769 contentContainerStyle={{ padding: 10 }}
770 >
771 <NativeText
772 selectable
773 style={{
774 color: props.colors.text,
775 fontFamily: "ui-monospace",
776 fontSize: 12,
777 lineHeight: 18,
778 }}
779 >
780 {props.comment.diff.trim()}
781 </NativeText>
782 </ScrollView>
783 ) : null}
784 {props.comment.text.length > 0 ? (
785 <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}>
786 <Text
787 selectable
788 className="text-[15px] leading-[21px]"
789 style={{ color: props.colors.text }}
790 >
791 {props.comment.text}
792 </Text>
793 </View>
794 ) : null}
795 </View>
796 );
797});
798
799function buildReviewCommentPatch(comment: ReviewInlineComment): string {
800 const diff = comment.diff.trim();
801 if (!diff) {
802 return "";
803 }
804
805 if (diff.startsWith("diff --git ")) {
806 return diff;
807 }
808
809 const normalizedPath = comment.filePath.replaceAll("\\", "/");
810 return [
811 `diff --git a/${normalizedPath} b/${normalizedPath}`,
812 `--- a/${normalizedPath}`,
813 `+++ b/${normalizedPath}`,
814 diff,
815 ].join("\n");
816}
817
818function compactFileName(filePath: string): string {
819 const normalized = filePath.replaceAll("\\", "/");
820 const lastSlashIndex = normalized.lastIndexOf("/");
821 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;
822}
823
824const IOS_NAV_BAR_HEIGHT = 44;
825
826export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
827 const listRef = useRef<LegendListRef>(null);
828 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
829 const { width: viewportWidth } = useWindowDimensions();
830 const [copiedRowId, setCopiedRowId] = useState<string | null>(null);
831 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
832 const [expandedImage, setExpandedImage] = useState<{
833 uri: string;
834 headers?: Record<string, string>;
835 } | null>(null);
836 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
837 const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);
838 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
839 const insets = useSafeAreaInsets();
840 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
841 const bottomContentInset = props.contentBottomInset ?? 18;
842
843 const iconSubtleColor = useThemeColor("--color-icon-subtle");
844 const userBubbleColor = useThemeColor("--color-user-bubble");
845 const markdownStyles = useMarkdownStyles();
846 const reviewCommentColors = useReviewCommentColors();
847
848 useEffect(() => {
849 setCopiedRowId(null);
850 setExpandedWorkGroups({});
851 }, [props.threadId]);
852
853 useEffect(() => {
854 return () => {
855 if (copyFeedbackTimeoutRef.current) {
856 clearTimeout(copyFeedbackTimeoutRef.current);
857 }
858 };
859 }, []);
860
861 const onCopyWorkRow = useCallback((rowId: string, value: string) => {
862 void Clipboard.setStringAsync(value);
863 void Haptics.selectionAsync();
864 setCopiedRowId(rowId);
865 if (copyFeedbackTimeoutRef.current) {
866 clearTimeout(copyFeedbackTimeoutRef.current);
867 }
868 copyFeedbackTimeoutRef.current = setTimeout(() => {
869 setCopiedRowId((current) => (current === rowId ? null : current));
870 copyFeedbackTimeoutRef.current = null;
871 }, 1200);
872 }, []);
873
874 const onToggleWorkGroup = useCallback((groupId: string) => {
875 setExpandedWorkGroups((current) => ({
876 ...current,
877 [groupId]: !(current[groupId] ?? false),
878 }));
879 }, []);
880
881 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {
882 setExpandedImage({ uri, headers });
883 }, []);
884
885 const renderItem = useCallback(
886 (info: { item: ThreadFeedEntry; index: number }) =>
887 renderFeedEntry(info, {
888 bearerToken: props.bearerToken,
889 copiedRowId,
890 httpBaseUrl: props.httpBaseUrl,
891 expandedWorkGroups,
892 onCopyWorkRow,
893 onToggleWorkGroup,
894 onPressImage,
895 iconSubtleColor,
896 userBubbleColor,
897 markdownStyles,
898 reviewCommentColors,
899 reviewCommentBubbleWidth,
900 }),
901 [
902 copiedRowId,
903 expandedWorkGroups,
904 iconSubtleColor,
905 userBubbleColor,
906 markdownStyles,
907 reviewCommentColors,
908 reviewCommentBubbleWidth,
909 onCopyWorkRow,
910 onPressImage,
911 onToggleWorkGroup,
912 props.bearerToken,
913 props.httpBaseUrl,
914 ],
915 );
916
917 if (props.feed.length === 0) {
918 return (
919 <ScrollView
920 style={{ flex: 1 }}
921 contentInsetAdjustmentBehavior="never"
922 contentInset={{ top: topContentInset, bottom: bottomContentInset }}
923 contentOffset={{ x: 0, y: -topContentInset }}
924 scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }}
925 contentContainerStyle={{
926 flexGrow: 1,
927 paddingHorizontal: horizontalPadding,
928 }}
929 >
930 <EmptyState
931 title="No conversation yet"
932 detail="Ask the agent to inspect the repo, run a command, or continue the active thread."
933 />
934 </ScrollView>
935 );
936 }
937
938 return (
939 <>
940 <KeyboardAvoidingLegendList
941 ref={listRef}
942 key={props.threadId}
943 style={{ flex: 1 }}
944 alignItemsAtEnd
945 contentInsetAdjustmentBehavior="never"
946 contentInset={{ top: topContentInset, bottom: bottomContentInset }}
947 scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }}
948 data={props.feed as ThreadFeedEntry[]}
949 renderItem={renderItem}
950 keyExtractor={(entry) => `${entry.type}:${entry.id}`}
951 getItemType={(entry) =>
952 entry.type === "message" ? `message:${entry.message.role}` : entry.type
953 }
954 keyboardShouldPersistTaps="handled"
955 estimatedItemSize={180}
956 initialScrollAtEnd
957 maintainScrollAtEnd={{
958 on: { layout: true, itemLayout: true, dataChange: true },
959 }}
960 maintainScrollAtEndThreshold={0.1}
961 refreshing={props.refreshing ?? false}
962 onRefresh={props.onRefresh}
963 safeAreaInsetBottom={insets.bottom}
964 contentContainerStyle={{
965 paddingTop: 12,
966 paddingHorizontal: horizontalPadding,
967 }}
968 />
969
970 <ImageViewing
971 images={
972 expandedImage
973 ? [
974 {
975 uri: expandedImage.uri,
976 headers: expandedImage.headers,
977 },
978 ]
979 : []
980 }
981 imageIndex={0}
982 visible={expandedImage !== null}
983 onRequestClose={() => setExpandedImage(null)}
984 swipeToCloseEnabled
985 doubleTapToZoomEnabled
986 />
987 </>
988 );
989});
apps/mobile/src/features/threads/ThreadGitControls.tsx created+277
...@@ -0,0 +1,277 @@
1import type {
2 EnvironmentId,
3 GitRunStackedActionResult,
4 ProjectScript,
5 ThreadId,
6 VcsStatusResult,
7} from "@t3tools/contracts";
8import {
9 type GitActionRequestInput,
10 requiresDefaultBranchConfirmation,
11 resolveQuickAction,
12} from "@t3tools/client-runtime";
13import { useLocalSearchParams, useRouter } from "expo-router";
14import Stack from "expo-router/stack";
15import { useCallback, useMemo } from "react";
16import { Alert, Linking } from "react-native";
17import { buildThreadReviewRoutePath } from "../../lib/routes";
18import {
19 basename,
20 getTerminalStatusLabel,
21 projectScriptMenuIcon,
22 projectScriptMenuLabel,
23 type TerminalMenuSession,
24} from "../terminal/terminalMenu";
25
26function truncateMiddle(value: string, maxLength: number): string {
27 if (value.length <= maxLength) {
28 return value;
29 }
30
31 const headLength = Math.ceil((maxLength - 1) / 2);
32 const tailLength = Math.floor((maxLength - 1) / 2);
33 return `${value.slice(0, headLength)}…${value.slice(value.length - tailLength)}`;
34}
35
36function compactMenuBranchLabel(branch: string): string {
37 return truncateMiddle(branch, 24);
38}
39
40function compactMenuStatus(gitStatus: VcsStatusResult | null): string {
41 if (!gitStatus) {
42 return "Checking status";
43 }
44 if (!gitStatus.isRepo) {
45 return "Not a repo";
46 }
47
48 const parts: string[] = [];
49 if (gitStatus.hasWorkingTreeChanges) {
50 parts.push(`${gitStatus.workingTree.files.length} changed`);
51 } else if (gitStatus.aheadCount === 0 && gitStatus.behindCount === 0) {
52 parts.push("Clean");
53 }
54 if (gitStatus.aheadCount > 0) {
55 parts.push(`${gitStatus.aheadCount} ahead`);
56 }
57 if (gitStatus.behindCount > 0) {
58 parts.push(`${gitStatus.behindCount} behind`);
59 }
60 if (gitStatus.pr?.state === "open") {
61 parts.push(`PR #${gitStatus.pr.number}`);
62 }
63
64 return parts.join(" · ");
65}
66
67export function ThreadGitControls(props: {
68 readonly currentBranch: string | null;
69 readonly gitStatus: VcsStatusResult | null;
70 readonly gitOperationLabel: string | null;
71 readonly canOpenTerminal: boolean;
72 readonly projectScripts: ReadonlyArray<ProjectScript>;
73 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
74 readonly onOpenTerminal: (terminalId?: string | null) => void;
75 readonly onOpenNewTerminal: () => void;
76 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;
77 readonly onPull: () => Promise<void>;
78 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
79}) {
80 const router = useRouter();
81 const { environmentId, threadId } = useLocalSearchParams<{
82 environmentId: EnvironmentId;
83 threadId: ThreadId;
84 }>();
85 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
86
87 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";
88 const busy = gitOperationLabel !== null;
89 const isRepo = gitStatus?.isRepo ?? true;
90 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
91 const isDefaultRef = gitStatus?.isDefaultRef ?? false;
92
93 const quickAction = useMemo(
94 () =>
95 isRepo
96 ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote)
97 : {
98 label: "Git unavailable",
99 disabled: true,
100 kind: "show_hint" as const,
101 hint: "This workspace is not a git repository.",
102 },
103 [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo],
104 );
105
106 const quickActionHint = quickAction.disabled
107 ? (quickAction.hint ?? "This action is unavailable.")
108 : null;
109
110 const quickActionIcon = (() => {
111 if (quickAction.kind === "run_pull") return "arrow.down.circle";
112 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
113 if (quickAction.kind === "run_action") {
114 if (quickAction.action === "commit") return "checkmark.circle";
115 if (quickAction.action === "push" || quickAction.action === "commit_push")
116 return "arrow.up.circle";
117 }
118 return "arrow.up.right.circle";
119 })();
120
121 const openExistingPr = useCallback(async () => {
122 const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null;
123 if (!prUrl) {
124 Alert.alert("No open PR", "This branch does not have an open pull request.");
125 return;
126 }
127 try {
128 await Linking.openURL(prUrl);
129 } catch (error) {
130 Alert.alert(
131 "Unable to open PR",
132 error instanceof Error ? error.message : "An error occurred.",
133 );
134 }
135 }, [gitStatus]);
136
137 const runActionWithPrompt = useCallback(
138 async (input: GitActionRequestInput) => {
139 const confirmableAction =
140 input.action === "push" ||
141 input.action === "create_pr" ||
142 input.action === "commit_push" ||
143 input.action === "commit_push_pr"
144 ? input.action
145 : null;
146 const branchName = gitStatus?.refName;
147 if (
148 branchName &&
149 confirmableAction &&
150 !input.featureBranch &&
151 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
152 ) {
153 router.push({
154 pathname: "/threads/[environmentId]/[threadId]/git-confirm",
155 params: {
156 environmentId,
157 threadId,
158 confirmAction: confirmableAction,
159 branchName,
160 includesCommit: String(
161 input.action === "commit_push" || input.action === "commit_push_pr",
162 ),
163 },
164 });
165 return;
166 }
167
168 await onRunAction(input);
169 },
170 [environmentId, gitStatus, isDefaultRef, onRunAction, router, threadId],
171 );
172
173 const runQuickAction = useCallback(async () => {
174 if (quickAction.kind === "open_pr") {
175 await openExistingPr();
176 return;
177 }
178 if (quickAction.kind === "run_pull") {
179 await onPull();
180 return;
181 }
182 if (quickAction.kind === "run_action" && quickAction.action) {
183 await runActionWithPrompt({ action: quickAction.action });
184 }
185 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);
186
187 return (
188 <Stack.Toolbar placement="right">
189 <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground>
190 {props.projectScripts.length > 0 ? (
191 props.projectScripts.map((script) => (
192 <Stack.Toolbar.MenuAction
193 key={script.id}
194 icon={projectScriptMenuIcon(script.icon)}
195 onPress={() => void props.onRunProjectScript(script)}
196 subtitle={script.command}
197 >
198 <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>
199 </Stack.Toolbar.MenuAction>
200 ))
201 ) : (
202 <Stack.Toolbar.MenuAction
203 icon="play"
204 disabled
205 onPress={() => {}}
206 subtitle="This project has no saved scripts yet"
207 >
208 <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label>
209 </Stack.Toolbar.MenuAction>
210 )}
211 {props.terminalSessions.map((session) => (
212 <Stack.Toolbar.MenuAction
213 key={session.terminalId}
214 icon="terminal"
215 onPress={() => props.onOpenTerminal(session.terminalId)}
216 subtitle={[
217 getTerminalStatusLabel({
218 status: session.status,
219 hasRunningSubprocess: session.hasRunningSubprocess,
220 }),
221 basename(session.cwd),
222 ]
223 .filter(Boolean)
224 .join(" · ")}
225 >
226 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
227 </Stack.Toolbar.MenuAction>
228 ))}
229 <Stack.Toolbar.MenuAction
230 icon="plus"
231 onPress={props.onOpenNewTerminal}
232 subtitle="Start another shell for this thread"
233 >
234 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
235 </Stack.Toolbar.MenuAction>
236 </Stack.Toolbar.Menu>
237 <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
238 <Stack.Toolbar.MenuAction
239 icon="point.topleft.down.curvedto.point.bottomright.up"
240 disabled
241 onPress={() => {}}
242 subtitle={compactMenuStatus(gitStatus)}
243 >
244 <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label>
245 </Stack.Toolbar.MenuAction>
246 <Stack.Toolbar.MenuAction
247 icon={quickActionIcon}
248 disabled={quickAction.disabled}
249 onPress={() => void runQuickAction()}
250 subtitle={quickActionHint ?? undefined}
251 >
252 <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label>
253 </Stack.Toolbar.MenuAction>
254 <Stack.Toolbar.MenuAction
255 icon="text.bubble"
256 disabled={!isRepo}
257 onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))}
258 subtitle="Turn diffs and worktree changes"
259 >
260 <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label>
261 </Stack.Toolbar.MenuAction>
262 <Stack.Toolbar.MenuAction
263 icon="ellipsis.circle"
264 onPress={() =>
265 router.push({
266 pathname: "/threads/[environmentId]/[threadId]/git",
267 params: { environmentId, threadId },
268 })
269 }
270 subtitle="Commit, files, branches"
271 >
272 <Stack.Toolbar.Label>More</Stack.Toolbar.Label>
273 </Stack.Toolbar.MenuAction>
274 </Stack.Toolbar.Menu>
275 </Stack.Toolbar>
276 );
277}
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx created+261
...@@ -0,0 +1,261 @@
1import { SymbolView } from "expo-symbols";
2import { useCallback, useEffect, useMemo, useState } from "react";
3import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
4import * as Arr from "effect/Array";
5import * as Order from "effect/Order";
6import { Gesture, GestureDetector } from "react-native-gesture-handler";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import Animated, {
9 runOnJS,
10 useAnimatedStyle,
11 useSharedValue,
12 withTiming,
13} from "react-native-reanimated";
14import { useThemeColor } from "../../lib/useThemeColor";
15
16import { AppText as Text } from "../../components/AppText";
17import { StatusPill } from "../../components/StatusPill";
18import { groupProjectsByRepository } from "../../lib/repositoryGroups";
19import { scopedThreadKey } from "../../lib/scopedEntities";
20import { relativeTime } from "../../lib/time";
21import { threadStatusTone } from "./threadPresentation";
22import {
23 EnvironmentScopedProjectShell,
24 EnvironmentScopedThreadShell,
25} from "@t3tools/client-runtime";
26
27const threadActivityOrder = Order.mapInput(
28 Order.Struct({
29 activityAt: Order.flip(Order.Number),
30 title: Order.String,
31 }),
32 (thread: EnvironmentScopedThreadShell) => ({
33 activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),
34 title: thread.title,
35 }),
36);
37
38export function ThreadNavigationDrawer(props: {
39 readonly visible: boolean;
40 readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>;
41 readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>;
42 readonly selectedThreadKey: string | null;
43 readonly onClose: () => void;
44 readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void;
45 readonly onStartNewTask: () => void;
46}) {
47 const insets = useSafeAreaInsets();
48 const { width } = useWindowDimensions();
49 const drawerWidth = Math.min(width * 0.84, 360);
50 const [mounted, setMounted] = useState(props.visible);
51 const translateX = useSharedValue(-drawerWidth);
52 const overlayOpacity = useSharedValue(0);
53
54 const backdropColor = useThemeColor("--color-backdrop");
55 const drawerBg = useThemeColor("--color-drawer");
56 const drawerShadow = useThemeColor("--color-drawer-shadow");
57 const primaryForeground = useThemeColor("--color-primary-foreground");
58 const borderSubtleColor = useThemeColor("--color-border-subtle");
59
60 const repositoryGroups = useMemo(
61 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
62 [props.projects, props.threads],
63 );
64 const groupedThreads = useMemo(
65 () =>
66 repositoryGroups.map((group) => {
67 const threads: EnvironmentScopedThreadShell[] = [];
68 for (const projectGroup of group.projects) {
69 threads.push(...projectGroup.threads);
70 }
71 return {
72 key: group.key,
73 title: group.projects[0]?.project.title ?? group.title,
74 threads: Arr.sort(threads, threadActivityOrder),
75 };
76 }),
77 [repositoryGroups],
78 );
79
80 useEffect(() => {
81 if (props.visible) {
82 setMounted(true);
83 translateX.value = withTiming(0, { duration: 240 });
84 overlayOpacity.value = withTiming(1, { duration: 220 });
85 return;
86 }
87
88 overlayOpacity.value = withTiming(0, { duration: 180 });
89 translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => {
90 if (finished) {
91 runOnJS(setMounted)(false);
92 }
93 });
94 }, [drawerWidth, overlayOpacity, props.visible, translateX]);
95
96 const closeDrawer = useCallback(() => {
97 props.onClose();
98 }, [props]);
99
100 const panGesture = useMemo(
101 () =>
102 Gesture.Pan()
103 .activeOffsetX([-12, 12])
104 .failOffsetY([-24, 24])
105 .onUpdate((event) => {
106 translateX.value = Math.min(0, event.translationX);
107 })
108 .onEnd((event) => {
109 const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500;
110 if (shouldClose) {
111 runOnJS(closeDrawer)();
112 return;
113 }
114
115 translateX.value = withTiming(0, { duration: 180 });
116 }),
117 [closeDrawer, drawerWidth, translateX],
118 );
119
120 const drawerStyle = useAnimatedStyle(() => ({
121 transform: [{ translateX: translateX.value }],
122 }));
123
124 const backdropStyle = useAnimatedStyle(() => ({
125 opacity: overlayOpacity.value,
126 }));
127
128 if (!mounted) {
129 return null;
130 }
131
132 return (
133 <Modal transparent visible={mounted} onRequestClose={props.onClose} statusBarTranslucent>
134 <View style={{ flex: 1 }}>
135 <Animated.View
136 pointerEvents="none"
137 style={[
138 {
139 position: "absolute",
140 left: 0,
141 right: 0,
142 top: 0,
143 bottom: 0,
144 backgroundColor: backdropColor,
145 },
146 backdropStyle,
147 ]}
148 />
149 <Pressable
150 style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }}
151 onPress={props.onClose}
152 />
153
154 <GestureDetector gesture={panGesture}>
155 <Animated.View
156 style={[
157 {
158 position: "absolute",
159 left: 0,
160 top: 0,
161 bottom: 0,
162 width: drawerWidth,
163 backgroundColor: drawerBg,
164 paddingTop: insets.top + 10,
165 paddingBottom: Math.max(insets.bottom, 18),
166 boxShadow: `20px 0 36px ${String(drawerShadow)}`,
167 },
168 drawerStyle,
169 ]}
170 >
171 <View className="flex-row items-center justify-between px-4 pb-5">
172 <Text className="text-[26px] font-t3-bold">Threads</Text>
173 <Pressable
174 onPress={() => {
175 props.onClose();
176 props.onStartNewTask();
177 }}
178 className="h-11 w-11 items-center justify-center rounded-full bg-primary"
179 >
180 <SymbolView
181 name="square.and.pencil"
182 size={17}
183 tintColor={primaryForeground}
184 type="monochrome"
185 />
186 </Pressable>
187 </View>
188
189 <ScrollView
190 showsVerticalScrollIndicator={false}
191 contentInset={{ bottom: Math.max(insets.bottom, 18) + 12 }}
192 contentContainerStyle={{
193 gap: 20,
194 paddingHorizontal: 14,
195 }}
196 >
197 {groupedThreads.map((group) => (
198 <View key={group.key} className="gap-3">
199 <Text
200 className="px-1 text-[15px] font-t3-bold text-foreground-muted"
201 style={{ letterSpacing: -0.2 }}
202 >
203 {group.title}
204 </Text>
205
206 <View className="overflow-hidden rounded-[22px] bg-card">
207 {group.threads.length === 0 ? (
208 <View className="px-4 py-4">
209 <Text className="text-[14px] font-medium text-foreground-tertiary">
210 No threads yet
211 </Text>
212 </View>
213 ) : (
214 group.threads.map((thread, index) => {
215 const threadKey = scopedThreadKey(thread.environmentId, thread.id);
216 const selected = props.selectedThreadKey === threadKey;
217
218 return (
219 <Pressable
220 key={threadKey}
221 onPress={() => {
222 props.onSelectThread(thread);
223 props.onClose();
224 }}
225 style={{
226 paddingHorizontal: 16,
227 paddingVertical: 15,
228 borderTopWidth: index === 0 ? 0 : 1,
229 borderTopColor: borderSubtleColor,
230 backgroundColor: selected ? undefined : "transparent",
231 }}
232 className={selected ? "bg-subtle" : undefined}
233 >
234 <View className="flex-row items-start justify-between gap-3">
235 <View className="flex-1 gap-1">
236 <Text className="text-[16px] font-t3-bold" numberOfLines={1}>
237 {thread.title}
238 </Text>
239 <Text
240 className="text-[13px] font-medium text-foreground-muted"
241 numberOfLines={1}
242 >
243 {relativeTime(thread.updatedAt ?? thread.createdAt)}
244 </Text>
245 </View>
246 <StatusPill {...threadStatusTone(thread)} />
247 </View>
248 </Pressable>
249 );
250 })
251 )}
252 </View>
253 </View>
254 ))}
255 </ScrollView>
256 </Animated.View>
257 </GestureDetector>
258 </View>
259 </Modal>
260 );
261}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx created+418
...@@ -0,0 +1,418 @@
1import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2import { useCallback, useMemo, useState } from "react";
3import * as Arr from "effect/Array";
4import * as Option from "effect/Option";
5import { pipe } from "effect/Function";
6import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
7import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
8import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
9import { useThemeColor } from "../../lib/useThemeColor";
10import { useVcsStatus, vcsStatusManager } from "../../state/use-vcs-status";
11import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
12
13import { EmptyState } from "../../components/EmptyState";
14import { LoadingScreen } from "../../components/LoadingScreen";
15import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes";
16import { scopedThreadKey } from "../../lib/scopedEntities";
17import { connectionTone } from "../connection/connectionTone";
18
19import { useRemoteCatalog } from "../../state/use-remote-catalog";
20import {
21 useRemoteConnectionStatus,
22 useRemoteEnvironmentState,
23} from "../../state/use-remote-environment-registry";
24import { useKnownTerminalSessions } from "../../state/use-terminal-session";
25import { useSelectedThreadDetail } from "../../state/use-thread-detail";
26import { useThreadSelection } from "../../state/use-thread-selection";
27import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
28import {
29 buildTerminalMenuSessions,
30 nextOpenTerminalId,
31 resolveProjectScriptTerminalId,
32} from "../terminal/terminalMenu";
33import {
34 resolvePreferredThreadWorktreePath,
35 stagePendingTerminalLaunch,
36} from "../terminal/terminalLaunchContext";
37import { terminalDebugLog } from "../terminal/terminalDebugLog";
38import { ThreadDetailScreen } from "./ThreadDetailScreen";
39import { ThreadGitControls } from "./ThreadGitControls";
40import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
41import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands";
42import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
43import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
44import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
45import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
46import { useThreadComposerState } from "../../state/use-thread-composer-state";
47
48function firstRouteParam(value: string | string[] | undefined): string | null {
49 if (Array.isArray(value)) {
50 return value[0] ?? null;
51 }
52
53 return value ?? null;
54}
55
56export function ThreadRouteScreen() {
57 const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } =
58 useRemoteEnvironmentState();
59 const { connectionState, connectionError: aggregateConnectionError } =
60 useRemoteConnectionStatus();
61 const { projects, threads } = useRemoteCatalog();
62 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
63 useThreadSelection();
64 const selectedThreadDetail = useSelectedThreadDetail();
65 const { selectedThreadCwd } = useSelectedThreadWorktree();
66 const composer = useThreadComposerState();
67 const gitState = useSelectedThreadGitState();
68 const gitActions = useSelectedThreadGitActions();
69 const requests = useSelectedThreadRequests();
70 const commands = useSelectedThreadCommands({
71 refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus,
72 });
73 const refreshSelectedThread = commands.onRefresh;
74 const router = useRouter();
75 const params = useLocalSearchParams<{
76 environmentId?: string | string[];
77 threadId?: string | string[];
78 }>();
79 const [drawerVisible, setDrawerVisible] = useState(false);
80 const environmentIdRaw = firstRouteParam(params.environmentId);
81 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
82 const threadId = firstRouteParam(params.threadId);
83 const routeEnvironmentRuntime = environmentId
84 ? (environmentStateById[environmentId] ?? null)
85 : null;
86 const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState;
87 const routeConnectionError =
88 pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError;
89
90 /* ─── Native header theming ──────────────────────────────────────── */
91 const isDark = useColorScheme() === "dark";
92 const iconColor = String(useThemeColor("--color-icon"));
93 const foregroundColor = String(useThemeColor("--color-foreground"));
94 const secondaryFg = isDark ? "#a3a3a3" : "#525252";
95
96 /* ─── Git status for native header trigger ───────────────────────── */
97 const gitStatus = useVcsStatus({
98 environmentId: selectedThread?.environmentId ?? null,
99 cwd: selectedThreadCwd,
100 });
101 const knownTerminalSessions = useKnownTerminalSessions({
102 environmentId: selectedThread?.environmentId ?? null,
103 threadId: selectedThread?.id ?? null,
104 });
105 const terminalMenuSessions = useMemo(
106 () =>
107 buildTerminalMenuSessions({
108 knownSessions: knownTerminalSessions,
109 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
110 }),
111 [knownTerminalSessions, selectedThreadProject?.workspaceRoot],
112 );
113 const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null;
114
115 /* ─── Git action progress (for overlay banner) ──────────────────── */
116 const gitActionProgressTarget = useMemo(
117 () => ({
118 environmentId: selectedThread?.environmentId ?? null,
119 cwd: selectedThreadCwd,
120 }),
121 [selectedThread?.environmentId, selectedThreadCwd],
122 );
123 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
124
125 const handleRefreshGitStatus = useCallback(async () => {
126 if (!selectedThread) return;
127 await vcsStatusManager.refresh({
128 environmentId: selectedThread.environmentId,
129 cwd: selectedThreadCwd,
130 });
131 }, [selectedThread, selectedThreadCwd]);
132
133 /** Wraps thread refresh + git status refresh for pull-to-refresh */
134 const handleRefreshAll = useCallback(async () => {
135 await refreshSelectedThread();
136 await handleRefreshGitStatus();
137 }, [handleRefreshGitStatus, refreshSelectedThread]);
138
139 const handleOpenDrawer = useCallback(() => {
140 setDrawerVisible(true);
141 }, []);
142
143 const handleOpenConnectionEditor = useCallback(() => {
144 void router.push("/connections");
145 }, [router]);
146
147 const handleOpenTerminal = useCallback(
148 (nextTerminalId?: string | null) => {
149 terminalDebugLog("terminal-menu:open-existing", {
150 terminalId: nextTerminalId ?? null,
151 hasThread: Boolean(selectedThread),
152 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
153 });
154
155 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
156 return;
157 }
158
159 void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId));
160 },
161 [router, selectedThread, selectedThreadProject?.workspaceRoot],
162 );
163
164 const handleOpenNewTerminal = useCallback(() => {
165 terminalDebugLog("terminal-menu:open-new", {
166 hasThread: Boolean(selectedThread),
167 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
168 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
169 });
170
171 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
172 return;
173 }
174
175 const nextId = nextOpenTerminalId({
176 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
177 });
178 void router.push(buildThreadTerminalNavigation(selectedThread, nextId));
179 }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
180
181 const handleRunProjectScript = useCallback(
182 async (script: ProjectScript) => {
183 terminalDebugLog("project-script:press", {
184 scriptId: script.id,
185 command: script.command,
186 hasThread: Boolean(selectedThread),
187 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
188 });
189
190 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
191 terminalDebugLog("project-script:abort", {
192 scriptId: script.id,
193 reason: "no-thread-or-workspace",
194 });
195 return;
196 }
197
198 const targetTerminalId = resolveProjectScriptTerminalId({
199 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
200 hasRunningTerminal: terminalMenuSessions.some(
201 (session) => session.status === "running" || session.status === "starting",
202 ),
203 });
204 const preferredWorktreePath = resolvePreferredThreadWorktreePath({
205 threadShellWorktreePath: selectedThread.worktreePath ?? null,
206 threadDetailWorktreePath: selectedThreadDetailWorktreePath,
207 });
208 const cwd = projectScriptCwd({
209 project: { cwd: selectedThreadProject.workspaceRoot },
210 worktreePath: preferredWorktreePath,
211 });
212 const env = projectScriptRuntimeEnv({
213 project: { cwd: selectedThreadProject.workspaceRoot },
214 worktreePath: preferredWorktreePath,
215 });
216 stagePendingTerminalLaunch({
217 target: {
218 environmentId: selectedThread.environmentId,
219 threadId: selectedThread.id,
220 terminalId: targetTerminalId,
221 },
222 launch: {
223 cwd,
224 worktreePath: preferredWorktreePath,
225 env,
226 initialInput: `${script.command}\r`,
227 },
228 });
229 terminalDebugLog("project-script:staged", {
230 scriptId: script.id,
231 terminalId: targetTerminalId,
232 cwd,
233 worktreePath: preferredWorktreePath,
234 });
235
236 void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId));
237 },
238 [
239 router,
240 selectedThread,
241 selectedThreadDetailWorktreePath,
242 selectedThreadProject,
243 terminalMenuSessions,
244 ],
245 );
246
247 if (!environmentId || !threadId) {
248 return <LoadingScreen message="Opening thread…" />;
249 }
250
251 if (!selectedThread) {
252 const stillHydrating =
253 isLoadingSavedConnection ||
254 routeConnectionState === "connecting" ||
255 routeConnectionState === "reconnecting";
256
257 if (stillHydrating) {
258 return <LoadingScreen message="Opening thread…" />;
259 }
260
261 return (
262 <ScrollView
263 contentInsetAdjustmentBehavior="automatic"
264 contentContainerStyle={{
265 flexGrow: 1,
266 justifyContent: "center",
267 paddingHorizontal: 24,
268 paddingVertical: 32,
269 }}
270 className="bg-screen flex-1"
271 >
272 <EmptyState
273 title="Thread unavailable"
274 detail="This thread is not available in the current mobile snapshot."
275 />
276 </ScrollView>
277 );
278 }
279
280 if (!selectedThreadDetail) {
281 return <LoadingScreen message="Opening thread…" />;
282 }
283
284 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);
285 const serverConfig =
286 routeEnvironmentRuntime?.serverConfig ??
287 pipe(
288 Object.values(environmentStateById),
289 Arr.map((runtime) => runtime.serverConfig),
290 Arr.findFirst((value) => value !== null),
291 Option.getOrNull,
292 );
293
294 const headerSubtitle = [
295 selectedThreadProject?.title ?? null,
296 selectedEnvironmentConnection?.environmentLabel ?? null,
297 ]
298 .filter(Boolean)
299 .join(" · ");
300
301 return (
302 <>
303 <Stack.Screen
304 options={{
305 headerShown: true,
306 headerTransparent: true,
307 headerStyle: { backgroundColor: "transparent" },
308 headerShadowVisible: false,
309 headerTintColor: iconColor,
310 headerBackTitle: "",
311 headerTitle: () => (
312 <Pressable
313 style={{ alignItems: "center", maxWidth: 200 }}
314 onLongPress={() => {
315 // TODO: trigger rename modal
316 }}
317 >
318 <RNText
319 numberOfLines={1}
320 style={{
321 fontFamily: "DMSans_700Bold",
322 fontSize: 18,
323 fontWeight: "900",
324 color: foregroundColor,
325 letterSpacing: -0.4,
326 }}
327 >
328 {selectedThreadDetail.title}
329 </RNText>
330 <RNText
331 numberOfLines={1}
332 style={{
333 fontFamily: "DMSans_700Bold",
334 fontSize: 12,
335 fontWeight: "700",
336 color: secondaryFg,
337 letterSpacing: 0.3,
338 }}
339 >
340 {headerSubtitle}
341 </RNText>
342 </Pressable>
343 ),
344 }}
345 />
346
347 <ThreadGitControls
348 currentBranch={selectedThreadDetail.branch}
349 gitStatus={gitStatus.data}
350 gitOperationLabel={gitState.gitOperationLabel}
351 canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)}
352 projectScripts={selectedThreadProject?.scripts ?? []}
353 terminalSessions={terminalMenuSessions}
354 onOpenTerminal={handleOpenTerminal}
355 onOpenNewTerminal={handleOpenNewTerminal}
356 onRunProjectScript={handleRunProjectScript}
357 onPull={gitActions.onPullSelectedThreadBranch}
358 onRunAction={gitActions.onRunSelectedThreadGitAction}
359 />
360
361 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
362
363 <View className="flex-1 bg-screen">
364 <ThreadDetailScreen
365 selectedThread={selectedThreadDetail}
366 screenTone={connectionTone(routeConnectionState)}
367 connectionError={routeConnectionError}
368 httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null}
369 bearerToken={selectedEnvironmentConnection?.bearerToken ?? null}
370 selectedThreadFeed={composer.selectedThreadFeed}
371 activeWorkStartedAt={composer.activeWorkStartedAt}
372 activePendingApproval={requests.activePendingApproval}
373 respondingApprovalId={requests.respondingApprovalId}
374 activePendingUserInput={requests.activePendingUserInput}
375 activePendingUserInputDrafts={requests.activePendingUserInputDrafts}
376 activePendingUserInputAnswers={requests.activePendingUserInputAnswers}
377 respondingUserInputId={requests.respondingUserInputId}
378 draftMessage={composer.draftMessage}
379 draftAttachments={composer.draftAttachments}
380 connectionStateLabel={routeConnectionState}
381 activeThreadBusy={composer.activeThreadBusy}
382 environmentId={selectedThread.environmentId}
383 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
384 selectedThreadQueueCount={composer.selectedThreadQueueCount}
385 onOpenDrawer={handleOpenDrawer}
386 onOpenConnectionEditor={handleOpenConnectionEditor}
387 onChangeDraftMessage={composer.onChangeDraftMessage}
388 onPickDraftImages={composer.onPickDraftImages}
389 onNativePasteImages={composer.onNativePasteImages}
390 onRemoveDraftImage={composer.onRemoveDraftImage}
391 onRefresh={handleRefreshAll}
392 serverConfig={serverConfig}
393 onStopThread={commands.onStopThread}
394 onSendMessage={composer.onSendMessage}
395 onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection}
396 onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode}
397 onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode}
398 onRespondToApproval={requests.onRespondToApproval}
399 onSelectUserInputOption={requests.onSelectUserInputOption}
400 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
401 onSubmitUserInput={requests.onSubmitUserInput}
402 />
403
404 <ThreadNavigationDrawer
405 visible={drawerVisible}
406 projects={projects}
407 threads={threads}
408 selectedThreadKey={selectedThreadKey}
409 onClose={() => setDrawerVisible(false)}
410 onSelectThread={(thread) => {
411 router.replace(buildThreadRoutePath(thread));
412 }}
413 onStartNewTask={() => router.push("/new")}
414 />
415 </View>
416 </>
417 );
418}
apps/mobile/src/features/threads/claudeEffortOptions.ts created+10
...@@ -0,0 +1,10 @@
1export const CLAUDE_AGENT_EFFORT_OPTIONS = [
2 "low",
3 "medium",
4 "high",
5 "xhigh",
6 "max",
7 "ultrathink",
8] as const;
9
10export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number];
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx created+201
...@@ -0,0 +1,201 @@
1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2import { useRouter } from "expo-router";
3import { useState } from "react";
4import { Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6import { useThemeColor } from "../../../lib/useThemeColor";
7
8import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
9import { useVcsStatus } from "../../../state/use-vcs-status";
10import { useThreadSelection } from "../../../state/use-thread-selection";
11import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
12import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
13import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
14import { SheetActionButton } from "./gitSheetComponents";
15
16export function GitBranchesSheet() {
17 const router = useRouter();
18 const insets = useSafeAreaInsets();
19 const { selectedThread } = useThreadSelection();
20 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
21 const gitState = useSelectedThreadGitState();
22 const gitActions = useSelectedThreadGitActions();
23
24 const borderColor = useThemeColor("--color-border");
25 const inputBorderColor = useThemeColor("--color-input-border");
26 const inputBg = useThemeColor("--color-input");
27 const foregroundColor = useThemeColor("--color-foreground");
28 const subtleStrongColor = useThemeColor("--color-subtle-strong");
29
30 const gitStatus = useVcsStatus({
31 environmentId: selectedThread?.environmentId ?? null,
32 cwd: selectedThreadCwd,
33 });
34
35 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
36 const currentWorktreePath = selectedThreadWorktreePath;
37 const availableBranches = gitState.selectedThreadBranches;
38 const branchesLoading = gitState.selectedThreadBranchesLoading;
39 const busy = gitState.gitOperationLabel !== null;
40
41 const [newBranchName, setNewBranchName] = useState("");
42 const [worktreeBaseBranch, setWorktreeBaseBranch] = useState(
43 currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel,
44 );
45 const [worktreeBranchName, setWorktreeBranchName] = useState("");
46
47 const disabledExistingBranchNames: Array<string> = [];
48 for (const branch of availableBranches) {
49 if (branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath) {
50 disabledExistingBranchNames.push(branch.name);
51 }
52 }
53 const disabledExistingBranches = new Set(disabledExistingBranchNames);
54
55 return (
56 <ScrollView
57 showsVerticalScrollIndicator={false}
58 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
59 contentContainerStyle={{
60 paddingHorizontal: 20,
61 paddingTop: 8,
62 gap: 16,
63 }}
64 >
65 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
66 <Text
67 className="text-foreground-secondary text-[11px] font-t3-bold uppercase"
68 style={{ letterSpacing: 1 }}
69 >
70 New branch
71 </Text>
72 <TextInput
73 value={newBranchName}
74 onChangeText={setNewBranchName}
75 placeholder="feature/mobile-polish"
76 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
77 style={{
78 borderWidth: 1,
79 borderColor: inputBorderColor,
80 backgroundColor: inputBg,
81 color: foregroundColor,
82 }}
83 />
84 <SheetActionButton
85 icon="plus"
86 label="Create & checkout"
87 tone="primary"
88 disabled={busy || newBranchName.trim().length === 0}
89 onPress={() => {
90 const branch = sanitizeFeatureBranchName(newBranchName.trim());
91 if (branch.length === 0) return;
92 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
93 setNewBranchName("");
94 router.dismiss();
95 });
96 }}
97 />
98 </View>
99
100 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
101 <Text
102 className="text-foreground-secondary text-[11px] font-t3-bold uppercase"
103 style={{ letterSpacing: 1 }}
104 >
105 New worktree
106 </Text>
107 <TextInput
108 value={worktreeBaseBranch}
109 onChangeText={setWorktreeBaseBranch}
110 placeholder="main"
111 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
112 style={{
113 borderWidth: 1,
114 borderColor: inputBorderColor,
115 backgroundColor: inputBg,
116 color: foregroundColor,
117 }}
118 />
119 <TextInput
120 value={worktreeBranchName}
121 onChangeText={setWorktreeBranchName}
122 placeholder="feature/mobile-thread"
123 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
124 style={{
125 borderWidth: 1,
126 borderColor: inputBorderColor,
127 backgroundColor: inputBg,
128 color: foregroundColor,
129 }}
130 />
131 <SheetActionButton
132 icon="square.split.2x1"
133 label="Create worktree"
134 tone="primary"
135 disabled={
136 busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0
137 }
138 onPress={() => {
139 const baseBranch = worktreeBaseBranch.trim();
140 const newBranch = worktreeBranchName.trim();
141 if (baseBranch.length === 0 || newBranch.length === 0) return;
142 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
143 setWorktreeBranchName("");
144 router.dismiss();
145 });
146 }}
147 />
148 </View>
149
150 <View className="gap-2">
151 <Text
152 className="text-foreground-secondary text-[11px] font-t3-bold uppercase"
153 style={{ letterSpacing: 1 }}
154 >
155 Existing branches
156 </Text>
157 {branchesLoading ? (
158 <Text className="text-foreground-secondary text-[13px] font-medium">
159 Loading branches...
160 </Text>
161 ) : null}
162 {!branchesLoading && availableBranches.length === 0 ? (
163 <Text className="text-foreground-secondary text-[13px] font-medium">
164 No local branches found.
165 </Text>
166 ) : null}
167 {availableBranches.map((branch) => {
168 const disabled = disabledExistingBranches.has(branch.name);
169 const subtitle = branch.worktreePath
170 ? branch.worktreePath === currentWorktreePath
171 ? "Checked out in this thread"
172 : "Checked out in another worktree"
173 : branch.isDefault
174 ? "Default branch"
175 : "Local branch";
176
177 return (
178 <Pressable
179 key={branch.name}
180 className="gap-1 rounded-[18px] border px-4 py-3"
181 disabled={busy || disabled}
182 style={{
183 borderColor: branch.current ? subtleStrongColor : borderColor,
184 opacity: busy || disabled ? 0.45 : 1,
185 }}
186 onPress={() => {
187 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
188 router.dismiss();
189 });
190 }}
191 >
192 <View className="absolute inset-0 rounded-[18px] bg-card" />
193 <Text className="text-foreground text-[15px] font-t3-bold">{branch.name}</Text>
194 <Text className="text-foreground-secondary text-[12px] font-medium">{subtitle}</Text>
195 </Pressable>
196 );
197 })}
198 </View>
199 </ScrollView>
200 );
201}
apps/mobile/src/features/threads/git/GitCommitSheet.tsx created+241
...@@ -0,0 +1,241 @@
1import { useRouter } from "expo-router";
2import { useCallback, useState } from "react";
3import { Pressable, ScrollView, View, useColorScheme } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";
5import { useThemeColor } from "../../../lib/useThemeColor";
6
7import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
8import { useVcsStatus } from "../../../state/use-vcs-status";
9import { useThreadSelection } from "../../../state/use-thread-selection";
10import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
11import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
12import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
13import { SheetActionButton } from "./gitSheetComponents";
14
15export function GitCommitSheet() {
16 const router = useRouter();
17 const insets = useSafeAreaInsets();
18 const isDarkMode = useColorScheme() === "dark";
19 const { selectedThread } = useThreadSelection();
20 const { selectedThreadCwd } = useSelectedThreadWorktree();
21 const gitState = useSelectedThreadGitState();
22 const gitActions = useSelectedThreadGitActions();
23
24 const borderColor = useThemeColor("--color-border");
25 const borderSubtleColor = useThemeColor("--color-border-subtle");
26 const inputBorderColor = useThemeColor("--color-input-border");
27 const inputBg = useThemeColor("--color-input");
28 const foregroundColor = useThemeColor("--color-foreground");
29
30 const gitStatus = useVcsStatus({
31 environmentId: selectedThread?.environmentId ?? null,
32 cwd: selectedThreadCwd,
33 });
34
35 const busy = gitState.gitOperationLabel !== null;
36 const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
37 const allFiles = gitStatus.data?.workingTree?.files ?? [];
38
39 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
40 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
41 const [isEditingFiles, setIsEditingFiles] = useState(false);
42
43 const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path));
44 const allSelected = excludedFiles.size === 0;
45 const noneSelected = selectedFiles.length === 0;
46 const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0);
47 const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0);
48 const selectedFilePreview = selectedFiles.slice(0, 3);
49
50 const runCommitAction = useCallback(
51 async (featureBranch: boolean) => {
52 const commitMessage = dialogCommitMessage.trim();
53 router.dismiss();
54 await gitActions.onRunSelectedThreadGitAction({
55 action: "commit",
56 featureBranch,
57 ...(commitMessage ? { commitMessage } : {}),
58 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
59 });
60 },
61 [allSelected, dialogCommitMessage, gitActions, router, selectedFiles],
62 );
63
64 return (
65 <ScrollView
66 keyboardShouldPersistTaps="handled"
67 showsVerticalScrollIndicator={false}
68 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
69 contentContainerStyle={{
70 paddingHorizontal: 20,
71 paddingTop: 8,
72 gap: 16,
73 }}
74 >
75 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
76 <View className="flex-row items-center justify-between gap-3">
77 <Text className="text-foreground-muted text-[13px] font-medium">Branch</Text>
78 <Text className="text-foreground text-[15px] font-t3-bold">
79 {gitStatus.data?.refName ?? "(detached HEAD)"}
80 </Text>
81 </View>
82 {isDefaultRef ? (
83 <Text
84 className="text-[12px] leading-[18px]"
85 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}
86 >
87 Warning: this is the default branch.
88 </Text>
89 ) : null}
90 </View>
91
92 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
93 <View className="flex-row items-center justify-between gap-3">
94 <View className="gap-1">
95 <Text className="text-foreground text-[16px] font-t3-bold">Files</Text>
96 <Text className="text-foreground-muted text-[12px] leading-[18px]">
97 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
98 </Text>
99 </View>
100 <View className="flex-row items-center gap-2">
101 {!allSelected && isEditingFiles ? (
102 <Pressable
103 className="bg-subtle rounded-full px-3 py-2"
104 onPress={() => setExcludedFiles(new Set())}
105 >
106 <Text className="text-foreground text-[11px] font-t3-bold uppercase">Reset</Text>
107 </Pressable>
108 ) : null}
109 <Pressable
110 className="bg-subtle rounded-full px-3 py-2"
111 onPress={() => setIsEditingFiles((current) => !current)}
112 >
113 <Text className="text-foreground text-[11px] font-t3-bold uppercase">
114 {isEditingFiles ? "Done" : "Edit"}
115 </Text>
116 </Pressable>
117 </View>
118 </View>
119
120 {allFiles.length === 0 ? (
121 <Text className="text-foreground-secondary text-[13px] leading-[19px]">
122 No changed files are available to commit.
123 </Text>
124 ) : !isEditingFiles ? (
125 <View className="gap-2">
126 {selectedFilePreview.map((file) => (
127 <View key={file.path} className="flex-row items-center justify-between gap-3">
128 <Text className="text-foreground flex-1 text-[13px] font-medium" numberOfLines={1}>
129 {file.path}
130 </Text>
131 <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}>
132 +{file.insertions}
133 </Text>
134 <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}>
135 -{file.deletions}
136 </Text>
137 </View>
138 ))}
139 {selectedFiles.length > selectedFilePreview.length ? (
140 <Text className="text-foreground-muted text-[12px] leading-[17px]">
141 +{selectedFiles.length - selectedFilePreview.length} more files
142 </Text>
143 ) : null}
144 </View>
145 ) : (
146 <View className="gap-2">
147 {allFiles.map((file) => {
148 const included = !excludedFiles.has(file.path);
149 return (
150 <Pressable
151 key={file.path}
152 className="rounded-[18px] border px-4 py-3"
153 style={{
154 borderColor: included ? borderColor : borderSubtleColor,
155 }}
156 onPress={() => {
157 setExcludedFiles((current) => {
158 const next = new Set(current);
159 if (next.has(file.path)) {
160 next.delete(file.path);
161 } else {
162 next.add(file.path);
163 }
164 return next;
165 });
166 }}
167 >
168 <View
169 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}
170 />
171 <View className="flex-row items-start justify-between gap-3">
172 <View className="flex-1 gap-1">
173 <Text
174 selectable
175 className={`text-[13px] font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}
176 >
177 {file.path}
178 </Text>
179 {!included ? (
180 <Text className="text-foreground-muted text-[11px] leading-[16px]">
181 Excluded from this commit
182 </Text>
183 ) : null}
184 </View>
185 <View className="items-end gap-1">
186 <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}>
187 +{file.insertions}
188 </Text>
189 <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}>
190 -{file.deletions}
191 </Text>
192 </View>
193 </View>
194 </Pressable>
195 );
196 })}
197 </View>
198 )}
199 </View>
200
201 <View className="gap-2">
202 <Text className="text-foreground text-[13px] font-t3-bold">Commit message</Text>
203 <TextInput
204 multiline
205 value={dialogCommitMessage}
206 onChangeText={setDialogCommitMessage}
207 placeholder="Leave empty to auto-generate"
208 textAlignVertical="top"
209 className="min-h-[128px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]"
210 style={{
211 minHeight: 128,
212 borderWidth: 1,
213 borderColor: inputBorderColor,
214 backgroundColor: inputBg,
215 color: foregroundColor,
216 }}
217 />
218 </View>
219
220 <View className="flex-row gap-3">
221 <View className="flex-1">
222 <SheetActionButton
223 icon="arrow.branch"
224 label="Commit on new branch"
225 disabled={noneSelected || busy}
226 onPress={() => void runCommitAction(true)}
227 />
228 </View>
229 <View className="flex-1">
230 <SheetActionButton
231 icon="checkmark.circle"
232 label="Commit"
233 tone="primary"
234 disabled={noneSelected || busy}
235 onPress={() => void runCommitAction(false)}
236 />
237 </View>
238 </View>
239 </ScrollView>
240 );
241}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx created+124
...@@ -0,0 +1,124 @@
1import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime";
2import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
3import * as Arr from "effect/Array";
4import * as Result from "effect/Result";
5import { useLocalSearchParams, useRouter } from "expo-router";
6import { useCallback, useMemo } from "react";
7import { View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10import { AppText as Text } from "../../../components/AppText";
11import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
12import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
13import { SheetActionButton } from "./gitSheetComponents";
14
15export function GitConfirmSheet() {
16 const router = useRouter();
17 const insets = useSafeAreaInsets();
18 const gitState = useSelectedThreadGitState();
19 const gitActions = useSelectedThreadGitActions();
20
21 const params = useLocalSearchParams<{
22 confirmAction?: string;
23 branchName?: string;
24 includesCommit?: string;
25 commitMessage?: string;
26 filePaths?: string;
27 }>();
28
29 const confirmAction = params.confirmAction as
30 | "push"
31 | "create_pr"
32 | "commit_push"
33 | "commit_push_pr"
34 | undefined;
35 const branchName = params.branchName ?? "";
36 const includesCommit = params.includesCommit === "true";
37
38 const copy = useMemo(
39 () =>
40 confirmAction
41 ? resolveDefaultBranchActionDialogCopy({
42 action: confirmAction,
43 branchName,
44 includesCommit,
45 })
46 : null,
47 [branchName, confirmAction, includesCommit],
48 );
49
50 const continuePendingAction = useCallback(async () => {
51 if (!confirmAction) return;
52 router.dismissAll();
53 await gitActions.onRunSelectedThreadGitAction({
54 action: confirmAction,
55 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
56 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
57 });
58 }, [confirmAction, gitActions, params, router]);
59
60 const movePendingActionToFeatureBranch = useCallback(async () => {
61 if (!confirmAction) return;
62 router.dismissAll();
63
64 if (includesCommit) {
65 await gitActions.onRunSelectedThreadGitAction({
66 action: confirmAction,
67 featureBranch: true,
68 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
69 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
70 });
71 return;
72 }
73
74 const branches =
75 gitState.selectedThreadBranches.length > 0
76 ? gitState.selectedThreadBranches
77 : await gitActions.refreshSelectedThreadBranches();
78 const newBranchName = resolveAutoFeatureBranchName(
79 Arr.filterMap(branches, (branch) =>
80 branch.isRemote ? Result.failVoid : Result.succeed(branch.name),
81 ),
82 );
83 await gitActions.onCreateSelectedThreadBranch(newBranchName);
84 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });
85 }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]);
86
87 return (
88 <View collapsable={false} className="flex-1 bg-sheet">
89 <View style={{ minHeight: 16, paddingTop: 8 }} />
90
91 <View className="items-center gap-1 px-5 pb-3 pt-4">
92 <Text
93 className="text-[12px] font-t3-bold uppercase text-foreground-muted"
94 style={{ letterSpacing: 1 }}
95 >
96 Confirm
97 </Text>
98 <Text className="text-center text-[28px] font-t3-bold">
99 {copy?.title ?? "Run action on default branch?"}
100 </Text>
101 <Text className="text-center text-foreground-secondary text-[13px] font-medium leading-[19px]">
102 {copy?.description ?? "Choose how to continue."}
103 </Text>
104 </View>
105
106 <View
107 className="gap-3 px-5"
108 style={{ paddingBottom: Math.max(insets.bottom, 18) + 8, paddingTop: 8 }}
109 >
110 <SheetActionButton
111 icon="arrow.right.circle"
112 label={copy?.continueLabel ?? "Continue"}
113 onPress={() => void continuePendingAction()}
114 />
115 <SheetActionButton
116 icon="arrow.branch"
117 label="Feature branch & continue"
118 tone="primary"
119 onPress={() => void movePendingActionToFeatureBranch()}
120 />
121 </View>
122 </View>
123 );
124}
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx created+248
...@@ -0,0 +1,248 @@
1import {
2 type GitActionRequestInput,
3 buildMenuItems,
4 getGitActionDisabledReason,
5 requiresDefaultBranchConfirmation,
6} from "@t3tools/client-runtime";
7import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useLocalSearchParams, useRouter } from "expo-router";
9import { SymbolView } from "expo-symbols";
10import { useCallback, useEffect, useMemo } from "react";
11import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
13import { useThemeColor } from "../../../lib/useThemeColor";
14
15import { AppText as Text } from "../../../components/AppText";
16import { buildThreadReviewRoutePath } from "../../../lib/routes";
17import { useVcsStatus } from "../../../state/use-vcs-status";
18import { useThreadSelection } from "../../../state/use-thread-selection";
19import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
20import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
21import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
22import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
23
24export function GitOverviewSheet() {
25 const router = useRouter();
26 const insets = useSafeAreaInsets();
27 const { environmentId, threadId } = useLocalSearchParams<{
28 environmentId: EnvironmentId;
29 threadId: ThreadId;
30 }>();
31 const { selectedThread } = useThreadSelection();
32 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
33 const gitState = useSelectedThreadGitState();
34 const gitActions = useSelectedThreadGitActions();
35
36 const iconColor = useThemeColor("--color-icon");
37 const borderColor = useThemeColor("--color-border");
38
39 const gitStatus = useVcsStatus({
40 environmentId: selectedThread?.environmentId ?? null,
41 cwd: selectedThreadCwd,
42 });
43
44 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
45 const currentWorktreePath = selectedThreadWorktreePath;
46 const gitOperationLabel = gitState.gitOperationLabel;
47 const busy = gitOperationLabel !== null;
48 const isRepo = gitStatus.data?.isRepo ?? true;
49 const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false;
50 const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
51
52 const menuItems = useMemo(
53 () => (isRepo ? buildMenuItems(gitStatus.data, busy, hasPrimaryRemote) : []),
54 [busy, gitStatus.data, hasPrimaryRemote, isRepo],
55 );
56
57 const sheetMenuItems = useMemo(
58 () =>
59 menuItems.map((item) => ({
60 item,
61 disabledReason: getGitActionDisabledReason({
62 item,
63 gitStatus: gitStatus.data,
64 isBusy: busy,
65 hasOriginRemote: hasPrimaryRemote,
66 }),
67 })),
68 [busy, gitStatus.data, hasPrimaryRemote, menuItems],
69 );
70
71 useEffect(() => {
72 void gitActions.refreshSelectedThreadGitStatus({ quiet: true });
73 }, [gitActions]);
74
75 const openExistingPr = useCallback(async () => {
76 const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null;
77 if (!prUrl) {
78 Alert.alert("No open PR", "This branch does not have an open pull request.");
79 return;
80 }
81 try {
82 await Linking.openURL(prUrl);
83 } catch (error) {
84 Alert.alert(
85 "Unable to open PR",
86 error instanceof Error ? error.message : "An error occurred.",
87 );
88 }
89 }, [gitStatus.data]);
90
91 const runActionWithPrompt = useCallback(
92 async (input: GitActionRequestInput) => {
93 const confirmableAction =
94 input.action === "push" ||
95 input.action === "create_pr" ||
96 input.action === "commit_push" ||
97 input.action === "commit_push_pr"
98 ? input.action
99 : null;
100 const branchName = gitStatus.data?.refName;
101 if (
102 branchName &&
103 confirmableAction &&
104 !input.featureBranch &&
105 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
106 ) {
107 router.push({
108 pathname: "/threads/[environmentId]/[threadId]/git-confirm",
109 params: {
110 environmentId,
111 threadId,
112 confirmAction: confirmableAction,
113 branchName,
114 includesCommit: String(
115 input.action === "commit_push" || input.action === "commit_push_pr",
116 ),
117 },
118 });
119 return;
120 }
121
122 router.dismiss();
123 await gitActions.onRunSelectedThreadGitAction(input);
124 },
125 [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId],
126 );
127
128 const onPressMenuItem = useCallback(
129 async (item: (typeof menuItems)[number]) => {
130 if (item.disabled) return;
131 if (item.kind === "open_pr") {
132 await openExistingPr();
133 return;
134 }
135 if (item.dialogAction === "commit") {
136 router.push({
137 pathname: "/threads/[environmentId]/[threadId]/git/commit",
138 params: { environmentId, threadId },
139 });
140 return;
141 }
142 if (item.dialogAction === "push") {
143 await runActionWithPrompt({ action: "push" });
144 return;
145 }
146 if (item.dialogAction === "create_pr") {
147 await runActionWithPrompt({ action: "create_pr" });
148 }
149 },
150 [environmentId, openExistingPr, router, runActionWithPrompt, threadId],
151 );
152
153 return (
154 <View collapsable={false} className="flex-1 bg-sheet">
155 <View style={{ minHeight: 16, paddingTop: 8 }} />
156
157 <View className="items-center gap-1 px-5 pb-3 pt-4">
158 <Pressable
159 className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
160 style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }}
161 disabled={busy}
162 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
163 >
164 <SymbolView
165 name="arrow.clockwise"
166 size={16}
167 tintColor={iconColor}
168 type="monochrome"
169 weight="medium"
170 />
171 </Pressable>
172 <Text
173 className="text-[12px] font-t3-bold uppercase text-foreground-muted"
174 style={{ letterSpacing: 1 }}
175 >
176 Branch
177 </Text>
178 <Text className="text-[28px] font-t3-bold">{currentBranchLabel}</Text>
179 <Text className="text-foreground-secondary text-[13px] font-medium leading-[19px]">
180 {statusSummary(gitStatus.data)}
181 </Text>
182 </View>
183
184 <ScrollView
185 showsVerticalScrollIndicator={false}
186 style={{ flex: 1 }}
187 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
188 contentContainerStyle={{
189 paddingHorizontal: 20,
190 paddingTop: 8,
191 gap: 14,
192 }}
193 >
194 <View className="overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1">
195 {sheetMenuItems.map(({ item, disabledReason }, index) => (
196 <View key={`${item.id}-${item.label}`}>
197 {index > 0 ? (
198 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
199 ) : null}
200 <SheetListRow
201 icon={menuItemIconName(item.icon)}
202 title={item.label}
203 subtitle={disabledReason}
204 disabled={item.disabled}
205 onPress={() => void onPressMenuItem(item)}
206 />
207 </View>
208 ))}
209 {(gitStatus.data?.behindCount ?? 0) > 0 ? (
210 <>
211 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
212 <SheetListRow
213 icon="arrow.down.circle"
214 title="Pull latest"
215 subtitle="Sync this branch with upstream"
216 disabled={busy || !isRepo}
217 onPress={() => void gitActions.onPullSelectedThreadBranch()}
218 />
219 </>
220 ) : null}
221 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
222 <SheetListRow
223 icon="text.bubble"
224 title="Review changes"
225 subtitle="Inspect turn diffs, worktree changes, and base branch diff"
226 disabled={busy || !isRepo}
227 onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))}
228 />
229 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
230 <SheetListRow
231 icon="point.topleft.down.curvedto.point.bottomright.up"
232 title="Branches & worktrees"
233 subtitle="Switch branch, create branch, or move to a worktree"
234 disabled={busy || !isRepo}
235 onPress={() =>
236 router.push({
237 pathname: "/threads/[environmentId]/[threadId]/git/branches",
238 params: { environmentId, threadId },
239 })
240 }
241 />
242 </View>
243
244 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
245 </ScrollView>
246 </View>
247 );
248}
apps/mobile/src/features/threads/git/gitSheetComponents.tsx created+161
...@@ -0,0 +1,161 @@
1import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";
3import { Pressable, View } from "react-native";
4import { useThemeColor } from "../../../lib/useThemeColor";
5import { AppText as Text } from "../../../components/AppText";
6
7/* ─── Shared sheet components ──────────────────────────────────────── */
8
9export function SheetActionButton(props: {
10 readonly icon: ComponentProps<typeof SymbolView>["name"];
11 readonly label: string;
12 readonly disabled?: boolean;
13 readonly tone?: "primary" | "secondary" | "danger";
14 readonly onPress: () => void;
15}) {
16 const primaryBg = useThemeColor("--color-primary");
17 const primaryFg = useThemeColor("--color-primary-foreground");
18 const dangerBg = useThemeColor("--color-danger");
19 const dangerBorder = useThemeColor("--color-danger-border");
20 const dangerFg = useThemeColor("--color-danger-foreground");
21 const secondaryBg = useThemeColor("--color-secondary");
22 const secondaryBorder = useThemeColor("--color-secondary-border");
23 const secondaryFg = useThemeColor("--color-secondary-foreground");
24
25 const tone = props.tone ?? "secondary";
26 const colors =
27 tone === "primary"
28 ? {
29 backgroundColor: primaryBg,
30 borderColor: "transparent",
31 textColor: primaryFg,
32 }
33 : tone === "danger"
34 ? {
35 backgroundColor: dangerBg,
36 borderColor: dangerBorder,
37 textColor: dangerFg,
38 }
39 : {
40 backgroundColor: secondaryBg,
41 borderColor: secondaryBorder,
42 textColor: secondaryFg,
43 };
44
45 return (
46 <Pressable
47 className="min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3"
48 disabled={props.disabled}
49 style={{
50 backgroundColor: colors.backgroundColor,
51 borderWidth: tone === "primary" ? 0 : 1,
52 borderColor: colors.borderColor,
53 opacity: props.disabled ? 0.45 : 1,
54 }}
55 onPress={props.onPress}
56 >
57 <SymbolView name={props.icon} size={16} tintColor={colors.textColor} type="monochrome" />
58 <Text
59 className="text-[12px] font-t3-bold uppercase"
60 style={{ color: colors.textColor, letterSpacing: 0.9 }}
61 >
62 {props.label}
63 </Text>
64 </Pressable>
65 );
66}
67
68export function MetaCard(props: { readonly label: string; readonly value: string }) {
69 return (
70 <View className="rounded-[18px] border border-border bg-card px-4 py-3">
71 <Text
72 className="text-foreground-muted text-[11px] font-t3-bold uppercase"
73 style={{ letterSpacing: 0.9 }}
74 >
75 {props.label}
76 </Text>
77 <Text selectable className="text-foreground text-[13px] font-medium" numberOfLines={1}>
78 {props.value}
79 </Text>
80 </View>
81 );
82}
83
84export function SheetListRow(props: {
85 readonly icon: ComponentProps<typeof SymbolView>["name"];
86 readonly title: string;
87 readonly subtitle?: string | null;
88 readonly disabled?: boolean;
89 readonly onPress: () => void;
90}) {
91 const iconColor = useThemeColor("--color-icon");
92 const iconSubtleColor = useThemeColor("--color-icon-subtle");
93
94 return (
95 <Pressable
96 className="flex-row items-center gap-3 px-1 py-3"
97 disabled={props.disabled}
98 style={{ opacity: props.disabled ? 0.45 : 1 }}
99 onPress={props.onPress}
100 >
101 <View className="bg-subtle h-9 w-9 items-center justify-center rounded-full">
102 <SymbolView name={props.icon} size={16} tintColor={iconColor} type="monochrome" />
103 </View>
104 <View className="flex-1 gap-0.5">
105 <Text className="text-foreground text-[16px] font-t3-bold">{props.title}</Text>
106 {props.subtitle ? (
107 <Text className="text-foreground-muted text-[12px] leading-[17px]">{props.subtitle}</Text>
108 ) : null}
109 </View>
110 <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" />
111 </Pressable>
112 );
113}
114
115/* ─── Shared utilities ──────────────────────────────────────────────── */
116
117export function menuItemIconName(
118 icon: "commit" | "push" | "pr",
119): ComponentProps<typeof SymbolView>["name"] {
120 if (icon === "commit") return "checkmark.circle";
121 if (icon === "push") return "arrow.up.circle";
122 return "arrow.up.right.circle";
123}
124
125export function statusSummary(
126 gitStatus: {
127 readonly isRepo?: boolean;
128 readonly hasWorkingTreeChanges?: boolean;
129 readonly workingTree?: { readonly files: readonly { readonly path: string }[] };
130 readonly aheadCount?: number;
131 readonly behindCount?: number;
132 readonly pr?: { readonly state?: string; readonly number?: number } | null;
133 } | null,
134): string {
135 if (!gitStatus) {
136 return "Loading branch status\u2026";
137 }
138
139 if (!gitStatus.isRepo) {
140 return "Not a git repository";
141 }
142
143 const parts: string[] = [];
144 if (gitStatus.hasWorkingTreeChanges) {
145 const fileCount = gitStatus.workingTree?.files.length ?? 0;
146 parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`);
147 } else {
148 parts.push("Clean");
149 }
150 if ((gitStatus.aheadCount ?? 0) > 0) {
151 parts.push(`${gitStatus.aheadCount} ahead`);
152 }
153 if ((gitStatus.behindCount ?? 0) > 0) {
154 parts.push(`${gitStatus.behindCount} behind`);
155 }
156 if (gitStatus.pr?.state === "open") {
157 parts.push(`PR #${gitStatus.pr.number} open`);
158 }
159
160 return parts.join(" \u00b7 ");
161}
apps/mobile/src/features/threads/new-task-flow-provider.tsx created+513
...@@ -0,0 +1,513 @@
1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
3import type {
4 EnvironmentId,
5 ModelSelection,
6 ProviderInteractionMode,
7 RuntimeMode,
8} from "@t3tools/contracts";
9import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";
10import * as Arr from "effect/Array";
11import { pipe } from "effect/Function";
12
13import type { DraftComposerImageAttachment } from "../../lib/composerImages";
14import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
15import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
16import { groupProjectsByRepository } from "../../lib/repositoryGroups";
17import { scopedProjectKey } from "../../lib/scopedEntities";
18import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs";
19import { useRemoteCatalog } from "../../state/use-remote-catalog";
20import {
21 setPendingConnectionError,
22 useRemoteEnvironmentState,
23} from "../../state/use-remote-environment-registry";
24import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime";
25import type { ClaudeAgentEffort } from "./claudeEffortOptions";
26
27type WorkspaceMode = "local" | "worktree";
28
29function normalizeSelectedWorktreePath(
30 project: EnvironmentScopedProjectShell,
31 branch: VcsRef,
32): string | null {
33 if (!branch.worktreePath) {
34 return null;
35 }
36
37 return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath;
38}
39
40export function branchBadgeLabel(input: {
41 readonly branch: VcsRef;
42 readonly project: EnvironmentScopedProjectShell | null;
43}): string | null {
44 if (input.branch.current) {
45 return "current";
46 }
47 if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) {
48 return "worktree";
49 }
50 if (input.branch.isDefault) {
51 return "default";
52 }
53 if (input.branch.isRemote) {
54 return "remote";
55 }
56 return null;
57}
58
59type NewTaskFlowContextValue = {
60 readonly logicalProjects: ReadonlyArray<{
61 readonly key: string;
62 readonly project: EnvironmentScopedProjectShell;
63 }>;
64 readonly selectedEnvironmentId: EnvironmentId | null;
65 readonly selectedProjectKey: string | null;
66 readonly selectedModelKey: string | null;
67 readonly workspaceMode: WorkspaceMode;
68 readonly selectedBranchName: string | null;
69 readonly selectedWorktreePath: string | null;
70 readonly prompt: string;
71 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
72 readonly submitting: boolean;
73 readonly branchQuery: string;
74 readonly branchesLoading: boolean;
75 readonly availableBranches: ReadonlyArray<VcsRef>;
76 readonly runtimeMode: RuntimeMode;
77 readonly interactionMode: ProviderInteractionMode;
78 readonly effort: ClaudeAgentEffort;
79 readonly fastMode: boolean;
80 readonly contextWindow: string;
81 readonly expandedProvider: string | null;
82 readonly environments: ReadonlyArray<{
83 readonly environmentId: EnvironmentId;
84 readonly environmentLabel: string;
85 }>;
86 readonly selectedProject: EnvironmentScopedProjectShell | null;
87 readonly modelOptions: ReadonlyArray<ModelOption>;
88 readonly selectedModel: ModelSelection | null;
89 readonly selectedModelOption: ModelOption | null;
90 readonly providerGroups: ReadonlyArray<ProviderGroup>;
91 readonly filteredBranches: ReadonlyArray<VcsRef>;
92 readonly reset: () => void;
93 readonly setProject: (project: EnvironmentScopedProjectShell) => void;
94 readonly selectEnvironment: (environmentId: EnvironmentId) => void;
95 readonly setSelectedModelKey: (key: string | null) => void;
96 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
97 readonly selectBranch: (branch: VcsRef) => void;
98 readonly setPrompt: (value: string) => void;
99 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
100 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
101 readonly removeAttachment: (imageId: string) => void;
102 readonly clearAttachments: () => void;
103 readonly setSubmitting: (value: boolean) => void;
104 readonly setBranchQuery: (value: string) => void;
105 readonly loadBranches: () => Promise<void>;
106 readonly setRuntimeMode: (value: RuntimeMode) => void;
107 readonly setInteractionMode: (value: ProviderInteractionMode) => void;
108 readonly setEffort: (value: ClaudeAgentEffort) => void;
109 readonly setFastMode: (value: boolean) => void;
110 readonly setContextWindow: (value: string) => void;
111 readonly setExpandedProvider: (value: string | null) => void;
112};
113
114const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null);
115
116export function NewTaskFlowProvider(props: React.PropsWithChildren) {
117 const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog();
118 const { savedConnectionsById } = useRemoteEnvironmentState();
119
120 const repositoryGroups = useMemo(
121 () => groupProjectsByRepository({ projects, threads }),
122 [projects, threads],
123 );
124 const logicalProjects = useMemo(
125 () =>
126 pipe(
127 repositoryGroups,
128 Arr.map((group) => {
129 const primaryProject = group.projects[0]?.project;
130 if (!primaryProject) {
131 return null;
132 }
133 return { key: group.key, project: primaryProject };
134 }),
135 Arr.filter(
136 (
137 entry,
138 ): entry is {
139 readonly key: string;
140 readonly project: EnvironmentScopedProjectShell;
141 } => entry !== null,
142 ),
143 ),
144 [repositoryGroups],
145 );
146
147 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(
148 projects[0]?.environmentId ?? null,
149 );
150 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
151 const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);
152 const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local");
153 const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null);
154 const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null);
155 const branchLoadVersionRef = useRef(0);
156 const [prompt, setPrompt] = useState("");
157 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
158 const [submitting, setSubmitting] = useState(false);
159 const [branchQuery, setBranchQuery] = useState("");
160 const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE);
161 const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>(
162 DEFAULT_PROVIDER_INTERACTION_MODE,
163 );
164 const [effort, setEffort] = useState<ClaudeAgentEffort>("high");
165 const [fastMode, setFastMode] = useState(false);
166 const [contextWindow, setContextWindow] = useState("1M");
167 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
168
169 const replaceAttachments = useCallback(
170 (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {
171 setAttachments(nextAttachments);
172 },
173 [],
174 );
175
176 const appendAttachments = useCallback(
177 (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {
178 setAttachments((current) => [...current, ...nextAttachments]);
179 },
180 [],
181 );
182
183 const removeAttachment = useCallback((imageId: string) => {
184 setAttachments((current) => current.filter((candidate) => candidate.id !== imageId));
185 }, []);
186
187 const clearAttachments = useCallback(() => {
188 setAttachments([]);
189 }, []);
190
191 const reset = useCallback(() => {
192 console.log("[new task flow] reset", {
193 defaultEnvironmentId: projects[0]?.environmentId ?? null,
194 projectCount: projects.length,
195 });
196 setSelectedEnvironmentId(projects[0]?.environmentId ?? null);
197 setSelectedProjectKey(null);
198 setSelectedModelKey(null);
199 setWorkspaceMode("local");
200 setSelectedBranchName(null);
201 setSelectedWorktreePath(null);
202 setPrompt("");
203 clearAttachments();
204 setSubmitting(false);
205 setBranchQuery("");
206 setRuntimeMode(DEFAULT_RUNTIME_MODE);
207 setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE);
208 setEffort("high");
209 setFastMode(false);
210 setContextWindow("1M");
211 setExpandedProvider(null);
212 }, [clearAttachments, projects]);
213
214 useEffect(() => {
215 if (selectedEnvironmentId !== null || projects.length === 0) {
216 return;
217 }
218
219 console.log("[new task flow] initializing environment", {
220 environmentId: projects[0]!.environmentId,
221 });
222 setSelectedEnvironmentId(projects[0]!.environmentId);
223 }, [projects, selectedEnvironmentId]);
224
225 const environments = useMemo(
226 () =>
227 pipe(
228 [
229 ...new Set(
230 pipe(
231 projects,
232 Arr.map((project) => project.environmentId),
233 ),
234 ),
235 ],
236 Arr.map((environmentId) => {
237 const environment = savedConnectionsById[environmentId];
238 if (!environment) {
239 return null;
240 }
241
242 return {
243 environmentId,
244 environmentLabel: environment.environmentLabel,
245 };
246 }),
247 Arr.filter(
248 (
249 entry,
250 ): entry is {
251 readonly environmentId: EnvironmentId;
252 readonly environmentLabel: string;
253 } => entry !== null,
254 ),
255 ),
256 [projects, savedConnectionsById],
257 );
258
259 const projectsForEnvironment = useMemo(
260 () =>
261 pipe(
262 projects,
263 Arr.filter((project) => project.environmentId === selectedEnvironmentId),
264 ),
265 [projects, selectedEnvironmentId],
266 );
267
268 const selectedProject =
269 projectsForEnvironment.find(
270 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
271 ) ??
272 projectsForEnvironment[0] ??
273 null;
274
275 const modelOptions = useMemo(
276 () =>
277 buildModelOptions(
278 selectedProject
279 ? (serverConfigByEnvironmentId[selectedProject.environmentId] ?? null)
280 : null,
281 selectedProject?.defaultModelSelection ?? null,
282 ),
283 [selectedProject, serverConfigByEnvironmentId],
284 );
285
286 const selectedModel =
287 modelOptions.find((option) => option.key === selectedModelKey)?.selection ??
288 selectedProject?.defaultModelSelection ??
289 modelOptions[0]?.selection ??
290 null;
291
292 const selectedModelOption =
293 modelOptions.find(
294 (option) =>
295 selectedModel &&
296 option.selection.instanceId === selectedModel.instanceId &&
297 option.selection.model === selectedModel.model,
298 ) ?? null;
299
300 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
301 const branchTarget = useMemo(
302 () => ({
303 environmentId: selectedProject?.environmentId ?? null,
304 cwd: selectedProject?.workspaceRoot ?? null,
305 query: null,
306 }),
307 [selectedProject?.environmentId, selectedProject?.workspaceRoot],
308 );
309 const branchState = useVcsRefs(branchTarget);
310 const branchesLoading = branchState.isPending;
311 const availableBranches = useMemo(
312 () =>
313 pipe(
314 branchState.data?.refs ?? [],
315 Arr.filter((branch) => !branch.isRemote),
316 ),
317 [branchState.data?.refs],
318 );
319
320 const filteredBranches = useMemo(() => {
321 const query = branchQuery.trim().toLowerCase();
322 if (query.length === 0) {
323 return availableBranches;
324 }
325
326 return pipe(
327 availableBranches,
328 Arr.filter((branch) => branch.name.toLowerCase().includes(query)),
329 );
330 }, [availableBranches, branchQuery]);
331
332 const setProject = useCallback((project: EnvironmentScopedProjectShell) => {
333 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
334 branchLoadVersionRef.current += 1;
335 setSelectedEnvironmentId(project.environmentId);
336 setSelectedProjectKey(nextProjectKey);
337 setSelectedBranchName(null);
338 setSelectedWorktreePath(null);
339 }, []);
340
341 const selectEnvironment = useCallback((environmentId: EnvironmentId) => {
342 branchLoadVersionRef.current += 1;
343 setSelectedEnvironmentId(environmentId);
344 setSelectedProjectKey(null);
345 setSelectedBranchName(null);
346 setSelectedWorktreePath(null);
347 }, []);
348
349 const selectBranch = useCallback(
350 (branch: VcsRef) => {
351 setSelectedBranchName(branch.name);
352 setSelectedWorktreePath(
353 selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null,
354 );
355 },
356 [selectedProject],
357 );
358
359 const loadBranches = useCallback(async () => {
360 if (!selectedProject) {
361 return;
362 }
363
364 const loadVersion = ++branchLoadVersionRef.current;
365 const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id);
366 try {
367 const result = await vcsRefManager.load({
368 environmentId: selectedProject.environmentId,
369 cwd: selectedProject.workspaceRoot,
370 query: null,
371 });
372 if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) {
373 return;
374 }
375 setPendingConnectionError(null);
376 const branches = pipe(
377 result?.refs ?? [],
378 Arr.filter((branch) => !branch.isRemote),
379 );
380
381 if (workspaceMode === "worktree" && !selectedBranchName) {
382 const preferredBranch =
383 branches.find((branch) => branch.current)?.name ??
384 branches.find((branch) => branch.isDefault)?.name ??
385 null;
386 if (preferredBranch) {
387 setSelectedBranchName(preferredBranch);
388 }
389 }
390 } catch {
391 if (loadVersion !== branchLoadVersionRef.current) {
392 return;
393 }
394 setPendingConnectionError("Failed to load branches.");
395 }
396 }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]);
397
398 const value = useMemo<NewTaskFlowContextValue>(
399 () => ({
400 logicalProjects,
401 selectedEnvironmentId,
402 selectedProjectKey,
403 selectedModelKey,
404 workspaceMode,
405 selectedBranchName,
406 selectedWorktreePath,
407 prompt,
408 attachments,
409 submitting,
410 branchQuery,
411 branchesLoading,
412 availableBranches,
413 runtimeMode,
414 interactionMode,
415 effort,
416 fastMode,
417 contextWindow,
418 expandedProvider,
419 environments,
420 selectedProject,
421 modelOptions,
422 selectedModel,
423 selectedModelOption,
424 providerGroups,
425 filteredBranches,
426 reset,
427 setProject,
428 selectEnvironment,
429 setSelectedModelKey,
430 setWorkspaceMode,
431 selectBranch,
432 setPrompt,
433 replaceAttachments,
434 appendAttachments,
435 removeAttachment,
436 clearAttachments,
437 setSubmitting,
438 setBranchQuery,
439 loadBranches,
440 setRuntimeMode,
441 setInteractionMode,
442 setEffort,
443 setFastMode,
444 setContextWindow,
445 setExpandedProvider,
446 }),
447 [
448 attachments,
449 availableBranches,
450 branchQuery,
451 branchesLoading,
452 contextWindow,
453 effort,
454 environments,
455 expandedProvider,
456 fastMode,
457 filteredBranches,
458 interactionMode,
459 loadBranches,
460 logicalProjects,
461 modelOptions,
462 prompt,
463 providerGroups,
464 replaceAttachments,
465 reset,
466 runtimeMode,
467 selectedBranchName,
468 selectedEnvironmentId,
469 selectedModel,
470 selectedModelKey,
471 selectedModelOption,
472 selectedProject,
473 selectedProjectKey,
474 selectedWorktreePath,
475 setProject,
476 selectBranch,
477 selectEnvironment,
478 submitting,
479 workspaceMode,
480 appendAttachments,
481 clearAttachments,
482 removeAttachment,
483 ],
484 );
485
486 useEffect(() => {
487 console.log("[new task flow] state", {
488 availableBranchCount: availableBranches.length,
489 environmentCount: environments.length,
490 logicalProjectCount: logicalProjects.length,
491 selectedEnvironmentId,
492 selectedProjectKey,
493 selectedProjectTitle: selectedProject?.title ?? null,
494 });
495 }, [
496 availableBranches.length,
497 environments.length,
498 logicalProjects.length,
499 selectedEnvironmentId,
500 selectedProject?.title,
501 selectedProjectKey,
502 ]);
503
504 return <NewTaskFlowContext.Provider value={value}>{props.children}</NewTaskFlowContext.Provider>;
505}
506
507export function useNewTaskFlow() {
508 const value = React.use(NewTaskFlowContext);
509 if (value === null) {
510 throw new Error("useNewTaskFlow must be used within NewTaskFlowProvider.");
511 }
512 return value;
513}
apps/mobile/src/features/threads/threadPresentation.ts created+53
...@@ -0,0 +1,53 @@
1import type { StatusTone } from "../../components/StatusPill";
2import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
3
4export function threadSortValue(thread: EnvironmentScopedThreadShell): number {
5 const candidate = Date.parse(thread.updatedAt ?? thread.createdAt);
6 return Number.isNaN(candidate) ? 0 : candidate;
7}
8
9export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTone {
10 const status = thread.session?.status;
11 if (status === "running") {
12 return {
13 label: "Running",
14 pillClassName: "bg-orange-500/12 dark:bg-orange-500/16",
15 textClassName: "text-orange-700 dark:text-orange-300",
16 };
17 }
18 if (status === "ready") {
19 return {
20 label: "Ready",
21 pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16",
22 textClassName: "text-emerald-700 dark:text-emerald-300",
23 };
24 }
25 if (status === "starting") {
26 return {
27 label: "Starting",
28 pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",
29 textClassName: "text-sky-700 dark:text-sky-300",
30 };
31 }
32 if (status === "error") {
33 return {
34 label: "Error",
35 pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
36 textClassName: "text-rose-700 dark:text-rose-300",
37 };
38 }
39 return {
40 label: "Idle",
41 pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16",
42 textClassName: "text-neutral-600 dark:text-neutral-300",
43 };
44}
45
46export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string): string | null {
47 if (!httpBaseUrl) {
48 return null;
49 }
50
51 const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl);
52 return url.toString();
53}
apps/mobile/src/features/threads/use-project-actions.ts created+262
...@@ -0,0 +1,262 @@
1import { useCallback } from "react";
2
3import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime";
4import {
5 CommandId,
6 DEFAULT_PROVIDER_INTERACTION_MODE,
7 DEFAULT_RUNTIME_MODE,
8 type EnvironmentId,
9 MessageId,
10 ThreadId,
11 type ModelSelection,
12 type ProviderInteractionMode,
13 type RuntimeMode,
14} from "@t3tools/contracts";
15import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
16import { uuidv4 } from "../../lib/uuid";
17
18import type { DraftComposerImageAttachment } from "../../lib/composerImages";
19import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
20import { getEnvironmentClient } from "../../state/environment-session-registry";
21import { environmentRuntimeManager } from "../../state/use-environment-runtime";
22import { vcsRefManager } from "../../state/use-vcs-refs";
23import { useRemoteCatalog } from "../../state/use-remote-catalog";
24import {
25 setPendingConnectionError,
26 useRemoteEnvironmentState,
27} from "../../state/use-remote-environment-registry";
28
29function useRefreshRemoteData() {
30 const { savedConnectionsById } = useRemoteEnvironmentState();
31
32 return useCallback(
33 async (environmentIds?: ReadonlyArray<EnvironmentId>) => {
34 const targets =
35 environmentIds ??
36 Object.values(savedConnectionsById).map((connection) => connection.environmentId);
37
38 await Promise.all(
39 targets.map(async (environmentId) => {
40 const client = getEnvironmentClient(environmentId);
41 if (!client) {
42 return;
43 }
44
45 try {
46 const serverConfig = await client.server.getConfig();
47 environmentRuntimeManager.patch({ environmentId }, (current) => ({
48 ...current,
49 serverConfig,
50 connectionError: null,
51 }));
52 } catch (error) {
53 environmentRuntimeManager.patch({ environmentId }, (current) => ({
54 ...current,
55 connectionError:
56 error instanceof Error ? error.message : "Failed to refresh remote data.",
57 }));
58 }
59 }),
60 );
61 },
62 [savedConnectionsById],
63 );
64}
65
66function deriveThreadTitleFromPrompt(value: string): string {
67 const trimmed = value.trim();
68 if (trimmed.length === 0) {
69 return "New thread";
70 }
71
72 const compact = trimmed.replace(/\s+/g, " ");
73 return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
74}
75
76export function useProjectActions() {
77 const { threads } = useRemoteCatalog();
78 const refreshRemoteData = useRefreshRemoteData();
79
80 const onCreateThreadWithOptions = useCallback(
81 async (input: {
82 readonly project: EnvironmentScopedProjectShell;
83 readonly modelSelection: ModelSelection;
84 readonly envMode: "local" | "worktree";
85 readonly branch: string | null;
86 readonly worktreePath: string | null;
87 readonly runtimeMode: RuntimeMode;
88 readonly interactionMode: ProviderInteractionMode;
89 readonly initialMessageText: string;
90 readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>;
91 }) => {
92 const client = getEnvironmentClient(input.project.environmentId);
93 if (!client) {
94 return null;
95 }
96
97 const metadata = makeTurnCommandMetadata();
98 const threadId = ThreadId.make(metadata.threadId);
99 const createdAt = metadata.createdAt;
100 const initialMessageText = input.initialMessageText.trim();
101 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);
102
103 if (initialMessageText.length === 0) {
104 return null;
105 }
106 if (input.envMode === "worktree" && !input.branch) {
107 return null;
108 }
109
110 const isWorktree = input.envMode === "worktree";
111
112 await client.orchestration.dispatchCommand({
113 type: "thread.turn.start",
114 commandId: CommandId.make(metadata.commandId),
115 threadId,
116 message: {
117 messageId: MessageId.make(metadata.messageId),
118 role: "user",
119 text: initialMessageText,
120 attachments: input.initialAttachments,
121 },
122 modelSelection: input.modelSelection,
123 titleSeed: nextTitle,
124 runtimeMode: input.runtimeMode,
125 interactionMode: input.interactionMode,
126 bootstrap: {
127 createThread: {
128 projectId: input.project.id,
129 title: nextTitle,
130 modelSelection: input.modelSelection,
131 runtimeMode: input.runtimeMode,
132 interactionMode: input.interactionMode,
133 branch: input.branch,
134 worktreePath: isWorktree ? null : input.worktreePath,
135 createdAt,
136 },
137 ...(isWorktree
138 ? {
139 prepareWorktree: {
140 projectCwd: input.project.workspaceRoot,
141 baseBranch: input.branch!,
142 branch: buildTemporaryWorktreeBranchName(uuidv4),
143 },
144 runSetupScript: true,
145 }
146 : {}),
147 },
148 createdAt,
149 });
150
151 await refreshRemoteData([input.project.environmentId]);
152 return {
153 environmentId: input.project.environmentId,
154 threadId,
155 };
156 },
157 [refreshRemoteData],
158 );
159
160 const onCreateThread = useCallback(
161 async (project: EnvironmentScopedProjectShell) => {
162 const latestProjectThread =
163 threads.find(
164 (thread) =>
165 thread.environmentId === project.environmentId && thread.projectId === project.id,
166 ) ?? null;
167 const modelSelection =
168 project.defaultModelSelection ?? latestProjectThread?.modelSelection ?? null;
169 if (!modelSelection) {
170 setPendingConnectionError("This project does not have a default model configured yet.");
171 return null;
172 }
173
174 return await onCreateThreadWithOptions({
175 project,
176 modelSelection,
177 envMode: "local",
178 branch: null,
179 worktreePath: null,
180 runtimeMode: DEFAULT_RUNTIME_MODE,
181 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
182 initialMessageText: "",
183 initialAttachments: [],
184 });
185 },
186 [onCreateThreadWithOptions, threads],
187 );
188
189 const onListProjectBranches = useCallback(
190 async (project: EnvironmentScopedProjectShell): Promise<ReadonlyArray<VcsRef>> => {
191 const client = getEnvironmentClient(project.environmentId);
192 if (!client) {
193 return [];
194 }
195
196 try {
197 const result = await vcsRefManager.load(
198 { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null },
199 client.vcs,
200 { limit: 100 },
201 );
202 return (result?.refs ?? []).filter((branch) => !branch.isRemote);
203 } catch (error) {
204 setPendingConnectionError(
205 error instanceof Error ? error.message : "Failed to load branches.",
206 );
207 return [];
208 }
209 },
210 [],
211 );
212
213 const onCreateProjectWorktree = useCallback(
214 async (
215 project: EnvironmentScopedProjectShell,
216 nextWorktree: {
217 readonly baseBranch: string;
218 readonly newBranch: string;
219 },
220 ): Promise<{
221 readonly branch: string;
222 readonly worktreePath: string;
223 } | null> => {
224 const client = getEnvironmentClient(project.environmentId);
225 if (!client) {
226 return null;
227 }
228
229 try {
230 const result = await client.vcs.createWorktree({
231 cwd: project.workspaceRoot,
232 refName: nextWorktree.baseBranch,
233 newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch),
234 path: null,
235 });
236 vcsRefManager.invalidate({
237 environmentId: project.environmentId,
238 cwd: project.workspaceRoot,
239 query: null,
240 });
241 return {
242 branch: result.worktree.refName,
243 worktreePath: result.worktree.path,
244 };
245 } catch (error) {
246 setPendingConnectionError(
247 error instanceof Error ? error.message : "Failed to create worktree.",
248 );
249 return null;
250 }
251 },
252 [],
253 );
254
255 return {
256 onCreateThread,
257 onCreateThreadWithOptions,
258 onListProjectBranches,
259 onCreateProjectWorktree,
260 onRefreshProjects: refreshRemoteData,
261 };
262}
apps/mobile/src/lib/cn.ts created+6
...@@ -0,0 +1,6 @@
1import { type ClassValue, clsx } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs));
6}
apps/mobile/src/lib/commandMetadata.test.ts created+36
...@@ -0,0 +1,36 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { makeQueuedMessageMetadata, makeTurnCommandMetadata } from "./commandMetadata";
4
5vi.mock("expo-crypto", () => ({
6 randomUUID: () => crypto.randomUUID(),
7}));
8
9describe("mobile command metadata", () => {
10 it("creates ids and timestamps for thread starts", () => {
11 const metadata = makeTurnCommandMetadata();
12
13 expect(metadata.commandId).toMatch(
14 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
15 );
16 expect(metadata.messageId).toMatch(
17 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
18 );
19 expect(metadata.threadId).toMatch(
20 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
21 );
22 expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
23 });
24
25 it("creates ids and timestamps for queued messages", () => {
26 const metadata = makeQueuedMessageMetadata();
27
28 expect(metadata.commandId).toMatch(
29 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
30 );
31 expect(metadata.messageId).toMatch(
32 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
33 );
34 expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
35 });
36});
apps/mobile/src/lib/commandMetadata.ts created+25
...@@ -0,0 +1,25 @@
1import { uuidv4 } from "./uuid";
2
3export interface TurnCommandMetadata {
4 readonly commandId: string;
5 readonly messageId: string;
6 readonly threadId: string;
7 readonly createdAt: string;
8}
9
10export function makeTurnCommandMetadata(): TurnCommandMetadata {
11 return {
12 commandId: uuidv4(),
13 messageId: uuidv4(),
14 threadId: uuidv4(),
15 createdAt: new Date().toISOString(),
16 };
17}
18
19export function makeQueuedMessageMetadata(): Omit<TurnCommandMetadata, "threadId"> {
20 return {
21 commandId: uuidv4(),
22 messageId: uuidv4(),
23 createdAt: new Date().toISOString(),
24 };
25}
apps/mobile/src/lib/composerImages.ts created+249
...@@ -0,0 +1,249 @@
1import {
2 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4 type UploadChatImageAttachment,
5} from "@t3tools/contracts";
6import { uuidv4 } from "./uuid";
7
8export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
9 readonly id: string;
10 readonly previewUri: string;
11}
12
13function estimateBase64ByteSize(base64: string): number {
14 const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
15 return Math.floor((base64.length * 3) / 4) - padding;
16}
17
18async function loadImagePicker() {
19 try {
20 return await import("expo-image-picker");
21 } catch (error) {
22 throw new Error("Image attachments are unavailable right now.", { cause: error });
23 }
24}
25
26async function loadClipboard() {
27 try {
28 return await import("expo-clipboard");
29 } catch (error) {
30 throw new Error("Clipboard paste is unavailable right now.", { cause: error });
31 }
32}
33
34export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{
35 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
36 readonly error: string | null;
37}> {
38 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
39 if (remainingSlots <= 0) {
40 return {
41 images: [],
42 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`,
43 };
44 }
45
46 let imagePicker: Awaited<ReturnType<typeof loadImagePicker>>;
47 try {
48 imagePicker = await loadImagePicker();
49 } catch (error) {
50 return {
51 images: [],
52 error:
53 error instanceof Error ? error.message : "Image attachments are unavailable right now.",
54 };
55 }
56
57 const permission = await imagePicker.requestMediaLibraryPermissionsAsync();
58 if (!permission.granted) {
59 return {
60 images: [],
61 error: "Allow photo library access to attach images.",
62 };
63 }
64
65 const result = await imagePicker.launchImageLibraryAsync({
66 mediaTypes: ["images"],
67 allowsMultipleSelection: true,
68 selectionLimit: remainingSlots,
69 base64: true,
70 quality: 1,
71 });
72
73 if (result.canceled) {
74 return {
75 images: [],
76 error: null,
77 };
78 }
79
80 const nextImages: DraftComposerImageAttachment[] = [];
81 let error: string | null = null;
82
83 for (const asset of result.assets) {
84 const mimeType = asset.mimeType?.toLowerCase();
85 if (!mimeType?.startsWith("image/")) {
86 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
87 continue;
88 }
89
90 const base64 = asset.base64;
91 if (!base64) {
92 error = `Failed to read '${asset.fileName ?? "image"}'.`;
93 continue;
94 }
95
96 const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64);
97 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
98 error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`;
99 continue;
100 }
101
102 nextImages.push({
103 id: uuidv4(),
104 type: "image",
105 name: asset.fileName ?? "image",
106 mimeType,
107 sizeBytes,
108 dataUrl: `data:${mimeType};base64,${base64}`,
109 previewUri: asset.uri,
110 });
111 }
112
113 return {
114 images: nextImages,
115 error,
116 };
117}
118
119export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{
120 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
121 readonly text: string | null;
122 readonly error: string | null;
123}> {
124 let clipboard: Awaited<ReturnType<typeof loadClipboard>>;
125 try {
126 clipboard = await loadClipboard();
127 } catch (error) {
128 return {
129 images: [],
130 text: null,
131 error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.",
132 };
133 }
134
135 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
136
137 if (await clipboard.hasImageAsync()) {
138 if (remainingSlots <= 0) {
139 return {
140 images: [],
141 text: null,
142 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`,
143 };
144 }
145 const image = await clipboard.getImageAsync({ format: "png" });
146 if (!image) {
147 return {
148 images: [],
149 text: null,
150 error: "Clipboard image is unavailable.",
151 };
152 }
153
154 const base64 = image.data.split(",")[1] ?? "";
155 const sizeBytes = estimateBase64ByteSize(base64);
156 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
157 return {
158 images: [],
159 text: null,
160 error: "Clipboard image exceeds the 10 MB attachment limit.",
161 };
162 }
163
164 return {
165 images: [
166 {
167 id: uuidv4(),
168 type: "image",
169 name: "pasted-image.png",
170 mimeType: "image/png",
171 sizeBytes,
172 dataUrl: image.data,
173 previewUri: image.data,
174 },
175 ],
176 text: null,
177 error: null,
178 };
179 }
180
181 if (await clipboard.hasStringAsync()) {
182 const text = await clipboard.getStringAsync();
183 return {
184 images: [],
185 text: text.length > 0 ? text : null,
186 error: text.length > 0 ? null : "Clipboard is empty.",
187 };
188 }
189
190 return {
191 images: [],
192 text: null,
193 error: "Clipboard does not contain pasteable text or image content.",
194 };
195}
196
197function mimeTypeFromUri(uri: string): string {
198 const ext = uri.split(".").pop()?.toLowerCase();
199 switch (ext) {
200 case "png":
201 return "image/png";
202 case "jpg":
203 case "jpeg":
204 return "image/jpeg";
205 case "gif":
206 return "image/gif";
207 case "webp":
208 return "image/webp";
209 case "heic":
210 return "image/heic";
211 default:
212 return "image/png";
213 }
214}
215
216export async function convertPastedImagesToAttachments(input: {
217 readonly uris: ReadonlyArray<string>;
218 readonly existingCount: number;
219}): Promise<ReadonlyArray<DraftComposerImageAttachment>> {
220 const { File } = await import("expo-file-system");
221 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
222 const uris = input.uris.slice(0, Math.max(0, remainingSlots));
223 const results: DraftComposerImageAttachment[] = [];
224
225 for (const uri of uris) {
226 try {
227 const file = new File(uri);
228 const base64 = await file.base64();
229 const sizeBytes = estimateBase64ByteSize(base64);
230 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
231 continue;
232 }
233 const mimeType = mimeTypeFromUri(uri);
234 results.push({
235 id: uuidv4(),
236 type: "image",
237 name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`,
238 mimeType,
239 sizeBytes,
240 dataUrl: `data:${mimeType};base64,${base64}`,
241 previewUri: uri,
242 });
243 } catch (error) {
244 console.warn("Failed to read pasted image", uri, error);
245 }
246 }
247
248 return results;
249}
apps/mobile/src/lib/connection.test.ts created+28
...@@ -0,0 +1,28 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { redactPairingCredential } from "./connection";
4
5vi.mock("./runtime", () => ({
6 mobileRemoteHttpRuntime: {
7 runPromise: vi.fn(),
8 },
9}));
10
11describe("mobile remote connection records", () => {
12 it("removes one-time bootstrap credentials before persisting pairing URLs", () => {
13 expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe(
14 "https://desktop.example/",
15 );
16 expect(redactPairingCredential("https://desktop.example/?token=bootstrap-token")).toBe(
17 "https://desktop.example/",
18 );
19 });
20
21 it("removes hosted pairing credentials while keeping the advertised host", () => {
22 expect(
23 redactPairingCredential(
24 "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&token=bootstrap-token&label=Desktop",
25 ),
26 ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop");
27 });
28});
apps/mobile/src/lib/connection.ts created+68
...@@ -0,0 +1,68 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import {
3 bootstrapRemoteBearerSession,
4 fetchRemoteEnvironmentDescriptor,
5} from "@t3tools/client-runtime";
6import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
7import { mobileRemoteHttpRuntime } from "./runtime";
8
9export interface RemoteConnectionInput {
10 readonly pairingUrl: string;
11}
12
13export interface SavedRemoteConnection {
14 readonly environmentId: EnvironmentId;
15 readonly environmentLabel: string;
16 readonly pairingUrl: string;
17 readonly displayUrl: string;
18 readonly httpBaseUrl: string;
19 readonly wsBaseUrl: string;
20 readonly bearerToken: string;
21}
22
23export type RemoteClientConnectionState =
24 | "idle"
25 | "connecting"
26 | "ready"
27 | "reconnecting"
28 | "disconnected";
29
30export function redactPairingCredential(pairingUrl: string): string {
31 const trimmed = pairingUrl.trim();
32 try {
33 return stripPairingTokenFromUrl(new URL(trimmed)).toString();
34 } catch {
35 return trimmed;
36 }
37}
38
39export async function bootstrapRemoteConnection(
40 input: RemoteConnectionInput,
41): Promise<SavedRemoteConnection> {
42 const target = resolveRemotePairingTarget({
43 pairingUrl: input.pairingUrl,
44 });
45
46 const descriptor = await mobileRemoteHttpRuntime.runPromise(
47 fetchRemoteEnvironmentDescriptor({
48 httpBaseUrl: target.httpBaseUrl,
49 }),
50 );
51
52 const bootstrap = await mobileRemoteHttpRuntime.runPromise(
53 bootstrapRemoteBearerSession({
54 httpBaseUrl: target.httpBaseUrl,
55 credential: target.credential,
56 }),
57 );
58
59 return {
60 environmentId: descriptor.environmentId,
61 environmentLabel: descriptor.label,
62 pairingUrl: redactPairingCredential(input.pairingUrl),
63 displayUrl: target.httpBaseUrl,
64 httpBaseUrl: target.httpBaseUrl,
65 wsBaseUrl: target.wsBaseUrl,
66 bearerToken: bootstrap.sessionToken,
67 };
68}
apps/mobile/src/lib/mobileLayout.ts created+37
...@@ -0,0 +1,37 @@
1function clamp(value: number, min: number, max: number): number {
2 return Math.min(Math.max(value, min), max);
3}
4
5export type MobileLayoutVariant = "compact" | "split";
6
7export interface MobileLayout {
8 readonly variant: MobileLayoutVariant;
9 readonly usesSplitView: boolean;
10 readonly listPaneWidth: number | null;
11 readonly shellPadding: number;
12}
13
14export function deriveMobileLayout(input: {
15 readonly width: number;
16 readonly height: number;
17}): MobileLayout {
18 const { width, height } = input;
19 const shortestEdge = Math.min(width, height);
20 const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700);
21
22 if (!wideEnoughForSplit) {
23 return {
24 variant: "compact",
25 usesSplitView: false,
26 listPaneWidth: null,
27 shellPadding: 0,
28 };
29 }
30
31 return {
32 variant: "split",
33 usesSplitView: true,
34 listPaneWidth: clamp(Math.round(width * 0.34), 320, 420),
35 shellPadding: width >= 1180 ? 20 : 14,
36 };
37}
apps/mobile/src/lib/modelOptions.ts created+97
...@@ -0,0 +1,97 @@
1import type { ModelSelection, ServerConfig as T3ServerConfig } from "@t3tools/contracts";
2
3export type ModelOption = {
4 readonly key: string;
5 readonly label: string;
6 readonly subtitle: string;
7 readonly providerKey: string;
8 readonly providerLabel: string;
9 readonly providerDriver: string;
10 readonly selection: ModelSelection;
11};
12
13export type ProviderGroup = {
14 readonly providerKey: string;
15 readonly providerLabel: string;
16 readonly models: ReadonlyArray<ModelOption>;
17};
18
19function providerDisplayLabel(provider: {
20 readonly displayName?: string | undefined;
21 readonly driver: string;
22 readonly instanceId: string;
23}): string {
24 if (provider.displayName) return provider.displayName;
25 if (provider.driver === "codex") return "Codex";
26 if (provider.driver === "claudeAgent") return "Claude";
27 return provider.instanceId;
28}
29
30export function buildModelOptions(
31 config: T3ServerConfig | null | undefined,
32 fallbackModelSelection: ModelSelection | null,
33): ReadonlyArray<ModelOption> {
34 const options = new Map<string, ModelOption>();
35
36 for (const provider of config?.providers ?? []) {
37 if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") {
38 continue;
39 }
40
41 const providerLabel = providerDisplayLabel(provider);
42 for (const model of provider.models) {
43 const key = `${provider.instanceId}:${model.slug}`;
44 options.set(key, {
45 key,
46 label: model.name,
47 subtitle: providerLabel,
48 providerKey: provider.instanceId,
49 providerLabel,
50 providerDriver: provider.driver,
51 selection: {
52 instanceId: provider.instanceId,
53 model: model.slug,
54 },
55 });
56 }
57 }
58
59 if (fallbackModelSelection) {
60 const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`;
61 if (!options.has(key)) {
62 const providerLabel = fallbackModelSelection.instanceId;
63 options.set(key, {
64 key,
65 label: fallbackModelSelection.model,
66 subtitle: providerLabel,
67 providerKey: fallbackModelSelection.instanceId,
68 providerLabel,
69 providerDriver: fallbackModelSelection.instanceId,
70 selection: fallbackModelSelection,
71 });
72 }
73 }
74
75 return [...options.values()];
76}
77
78export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> {
79 const groups = new Map<string, { providerLabel: string; models: ModelOption[] }>();
80 for (const option of options) {
81 const existing = groups.get(option.providerKey);
82 if (existing) {
83 existing.models.push(option);
84 } else {
85 groups.set(option.providerKey, {
86 providerLabel: option.providerLabel,
87 models: [option],
88 });
89 }
90 }
91
92 return [...groups.entries()].map(([providerKey, group]) => ({
93 providerKey,
94 providerLabel: group.providerLabel,
95 models: group.models,
96 }));
97}
apps/mobile/src/lib/repositoryGroups.test.ts created+197
...@@ -0,0 +1,197 @@
1import { describe, expect, it } from "vitest";
2
3import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4
5import { groupProjectsByRepository } from "./repositoryGroups";
6import {
7 EnvironmentScopedProjectShell,
8 EnvironmentScopedThreadShell,
9} from "@t3tools/client-runtime";
10
11function makeProject(
12 input: Partial<EnvironmentScopedProjectShell> &
13 Pick<EnvironmentScopedProjectShell, "environmentId" | "id" | "title">,
14): EnvironmentScopedProjectShell {
15 return {
16 workspaceRoot: `/workspaces/${input.id}`,
17 repositoryIdentity: null,
18 defaultModelSelection: null,
19 scripts: [],
20 createdAt: "2026-04-01T00:00:00.000Z",
21 updatedAt: "2026-04-01T00:00:00.000Z",
22 ...input,
23 };
24}
25
26function makeThread(
27 input: Partial<EnvironmentScopedThreadShell> &
28 Pick<
29 EnvironmentScopedThreadShell,
30 "environmentId" | "id" | "projectId" | "title" | "modelSelection"
31 >,
32): EnvironmentScopedThreadShell {
33 return {
34 runtimeMode: "full-access",
35 interactionMode: "default",
36 branch: null,
37 worktreePath: null,
38 latestTurn: null,
39 createdAt: "2026-04-01T00:00:00.000Z",
40 updatedAt: "2026-04-01T00:00:00.000Z",
41 archivedAt: null,
42 session: null,
43 latestUserMessageAt: null,
44 hasPendingApprovals: false,
45 hasPendingUserInput: false,
46 hasActionableProposedPlan: false,
47 ...input,
48 };
49}
50
51describe("groupProjectsByRepository", () => {
52 it("groups projects across environments by repository identity", () => {
53 const repoIdentity = {
54 canonicalKey: "github.com/t3tools/t3code",
55 locator: {
56 source: "git-remote" as const,
57 remoteName: "origin",
58 remoteUrl: "git@github.com:t3tools/t3code.git",
59 },
60 provider: "github",
61 owner: "t3tools",
62 name: "t3code",
63 displayName: "T3 Code",
64 };
65
66 const projects = [
67 makeProject({
68 environmentId: EnvironmentId.make("env-local"),
69 id: ProjectId.make("project-local"),
70 title: "T3 Code",
71 repositoryIdentity: repoIdentity,
72 }),
73 makeProject({
74 environmentId: EnvironmentId.make("env-staging"),
75 id: ProjectId.make("project-staging"),
76 title: "T3 Code",
77 repositoryIdentity: repoIdentity,
78 }),
79 ];
80
81 const threads = [
82 makeThread({
83 environmentId: EnvironmentId.make("env-staging"),
84 id: ThreadId.make("thread-2"),
85 projectId: ProjectId.make("project-staging"),
86 title: "Fix reconnect flow",
87 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
88 updatedAt: "2026-04-02T12:00:00.000Z",
89 }),
90 makeThread({
91 environmentId: EnvironmentId.make("env-local"),
92 id: ThreadId.make("thread-1"),
93 projectId: ProjectId.make("project-local"),
94 title: "Polish mobile shell",
95 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
96 updatedAt: "2026-04-03T12:00:00.000Z",
97 }),
98 ];
99
100 const groups = groupProjectsByRepository({ projects, threads });
101
102 expect(groups).toHaveLength(1);
103 expect(groups[0]).toMatchObject({
104 key: "github.com/t3tools/t3code",
105 title: "T3 Code",
106 subtitle: "t3tools/t3code",
107 projectCount: 2,
108 threadCount: 2,
109 });
110 expect(
111 groups[0]?.projects.map((entry) => ({
112 environmentId: entry.project.environmentId,
113 latestActivityAt: entry.latestActivityAt,
114 threads: entry.threads.map((thread) => thread.id),
115 })),
116 ).toEqual([
117 {
118 environmentId: "env-local",
119 latestActivityAt: "2026-04-03T12:00:00.000Z",
120 threads: ["thread-1"],
121 },
122 {
123 environmentId: "env-staging",
124 latestActivityAt: "2026-04-02T12:00:00.000Z",
125 threads: ["thread-2"],
126 },
127 ]);
128 expect(groups[0]?.latestActivityAt).toBe("2026-04-03T12:00:00.000Z");
129 });
130
131 it("orders threads, projects, and repository groups by latest activity", () => {
132 const projects = [
133 makeProject({
134 environmentId: EnvironmentId.make("env-local"),
135 id: ProjectId.make("older-project"),
136 title: "Older",
137 }),
138 makeProject({
139 environmentId: EnvironmentId.make("env-local"),
140 id: ProjectId.make("newer-project"),
141 title: "Newer",
142 }),
143 ];
144
145 const threads = [
146 makeThread({
147 environmentId: EnvironmentId.make("env-local"),
148 id: ThreadId.make("older-thread"),
149 projectId: ProjectId.make("older-project"),
150 title: "Older thread",
151 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
152 updatedAt: "2026-04-02T12:00:00.000Z",
153 }),
154 makeThread({
155 environmentId: EnvironmentId.make("env-local"),
156 id: ThreadId.make("newer-thread"),
157 projectId: ProjectId.make("older-project"),
158 title: "Newer thread",
159 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
160 updatedAt: "2026-04-04T12:00:00.000Z",
161 }),
162 makeThread({
163 environmentId: EnvironmentId.make("env-local"),
164 id: ThreadId.make("newest-thread"),
165 projectId: ProjectId.make("newer-project"),
166 title: "Newest thread",
167 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
168 updatedAt: "2026-04-05T12:00:00.000Z",
169 }),
170 ];
171
172 const groups = groupProjectsByRepository({ projects, threads });
173
174 expect(groups.map((group) => group.title)).toEqual(["Newer", "Older"]);
175 expect(groups[1]?.projects[0]?.threads.map((thread) => thread.id)).toEqual([
176 "newer-thread",
177 "older-thread",
178 ]);
179 });
180
181 it("falls back to a scoped project key when repository identity is unavailable", () => {
182 const projects = [
183 makeProject({
184 environmentId: EnvironmentId.make("env-local"),
185 id: ProjectId.make("project-local"),
186 title: "Scratchpad",
187 }),
188 ];
189
190 const groups = groupProjectsByRepository({ projects, threads: [] });
191
192 expect(groups).toHaveLength(1);
193 expect(groups[0]?.key).toBe("env-local:project-local");
194 expect(groups[0]?.title).toBe("Scratchpad");
195 expect(groups[0]?.subtitle).toBeNull();
196 });
197});
apps/mobile/src/lib/repositoryGroups.ts created+134
...@@ -0,0 +1,134 @@
1import * as Order from "effect/Order";
2import * as Arr from "effect/Array";
3import type { RepositoryIdentity } from "@t3tools/contracts";
4
5import { scopedProjectKey } from "./scopedEntities";
6import {
7 EnvironmentScopedProjectShell,
8 EnvironmentScopedThreadShell,
9} from "@t3tools/client-runtime";
10
11const DateDescending = Order.flip(Order.Date);
12
13export interface MobileRepositoryProjectGroup {
14 readonly key: string;
15 readonly project: EnvironmentScopedProjectShell;
16 readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>;
17 readonly latestActivityAt: string;
18}
19
20export interface MobileRepositoryGroup {
21 readonly key: string;
22 readonly title: string;
23 readonly subtitle: string | null;
24 readonly repositoryIdentity: RepositoryIdentity | null;
25 readonly projectCount: number;
26 readonly threadCount: number;
27 readonly latestActivityAt: string;
28 readonly projects: ReadonlyArray<MobileRepositoryProjectGroup>;
29}
30
31function compareIsoDateDescending(left: string, right: string): number {
32 return new Date(right).getTime() - new Date(left).getTime();
33}
34
35function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string {
36 return (
37 project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id)
38 );
39}
40
41function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string {
42 const identity = project.repositoryIdentity;
43 return identity?.displayName ?? identity?.name ?? project.title;
44}
45
46function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefined): string | null {
47 if (!identity) {
48 return null;
49 }
50 if (identity.owner && identity.name) {
51 return `${identity.owner}/${identity.name}`;
52 }
53 return identity.canonicalKey;
54}
55
56function deriveProjectLatestActivity(
57 project: EnvironmentScopedProjectShell,
58 threads: ReadonlyArray<EnvironmentScopedThreadShell>,
59): string {
60 const latestThread = threads[0];
61 return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt;
62}
63
64export function groupProjectsByRepository(input: {
65 readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>;
66 readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>;
67}): ReadonlyArray<MobileRepositoryGroup> {
68 const threadsByProjectKey = new Map<string, EnvironmentScopedThreadShell[]>();
69
70 for (const thread of input.threads) {
71 const key = scopedProjectKey(thread.environmentId, thread.projectId);
72 const existing = threadsByProjectKey.get(key);
73 if (existing) {
74 existing.push(thread);
75 } else {
76 threadsByProjectKey.set(key, [thread]);
77 }
78 }
79
80 const grouped = new Map<string, MobileRepositoryGroup>();
81
82 for (const project of input.projects) {
83 const key = deriveRepositoryGroupKey(project);
84 const projectKey = scopedProjectKey(project.environmentId, project.id);
85 const threads = Arr.sortWith(
86 threadsByProjectKey.get(projectKey) ?? [],
87 (s) => new Date(s.updatedAt ?? s.createdAt),
88 DateDescending,
89 );
90
91 const latestActivityAt = deriveProjectLatestActivity(project, threads);
92 const projectGroup: MobileRepositoryProjectGroup = {
93 key: projectKey,
94 project,
95 threads,
96 latestActivityAt,
97 };
98
99 const existing = grouped.get(key);
100 if (!existing) {
101 grouped.set(key, {
102 key,
103 title: deriveRepositoryTitle(project),
104 subtitle: deriveRepositorySubtitle(project.repositoryIdentity),
105 repositoryIdentity: project.repositoryIdentity ?? null,
106 projectCount: 1,
107 threadCount: threads.length,
108 latestActivityAt,
109 projects: [projectGroup],
110 });
111 continue;
112 }
113
114 grouped.set(key, {
115 ...existing,
116 title: existing.repositoryIdentity ? existing.title : deriveRepositoryTitle(project),
117 subtitle: existing.subtitle ?? deriveRepositorySubtitle(project.repositoryIdentity),
118 repositoryIdentity: existing.repositoryIdentity ?? project.repositoryIdentity ?? null,
119 projectCount: existing.projectCount + 1,
120 threadCount: existing.threadCount + threads.length,
121 latestActivityAt:
122 compareIsoDateDescending(existing.latestActivityAt, latestActivityAt) > 0
123 ? latestActivityAt
124 : existing.latestActivityAt,
125 projects: Arr.sortWith(
126 [...existing.projects, projectGroup],
127 (s) => new Date(s.latestActivityAt),
128 DateDescending,
129 ),
130 });
131 }
132
133 return Arr.sortWith(grouped.values(), (s) => new Date(s.latestActivityAt), DateDescending);
134}
apps/mobile/src/lib/routes.ts created+81
...@@ -0,0 +1,81 @@
1import type { Href, useRouter } from "expo-router";
2import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4
5import type { SelectedThreadRef } from "../state/remote-runtime-types";
6
7type Router = ReturnType<typeof useRouter>;
8
9type ThreadRouteInput =
10 | Pick<SelectedThreadRef, "environmentId" | "threadId">
11 | Pick<EnvironmentScopedThreadShell, "environmentId" | "id">;
12type PlainThreadRouteInput =
13 | {
14 environmentId: EnvironmentId;
15 threadId: ThreadId;
16 }
17 | {
18 environmentId: EnvironmentId;
19 id: ThreadId;
20 };
21
22export function buildThreadRoutePath(input: ThreadRouteInput | PlainThreadRouteInput): string {
23 const environmentId = input.environmentId;
24 const threadId = "threadId" in input ? input.threadId : input.id;
25
26 return `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`;
27}
28
29export function buildThreadReviewRoutePath(
30 input: ThreadRouteInput | PlainThreadRouteInput,
31): string {
32 return `${buildThreadRoutePath(input)}/review`;
33}
34
35export function buildThreadTerminalRoutePath(
36 input: ThreadRouteInput | PlainThreadRouteInput,
37 terminalId?: string | null,
38): string {
39 const basePath = `${buildThreadRoutePath(input)}/terminal`;
40 if (!terminalId) {
41 return basePath;
42 }
43
44 return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`;
45}
46
47/**
48 * Prefer this over {@link buildThreadTerminalRoutePath} with `router.push(string)` — Expo Router
49 * often does not merge query strings into `useLocalSearchParams`, which breaks terminal bootstrap
50 * (`requestedTerminalId` stays null while the UI assumes `default`).
51 */
52export function buildThreadTerminalNavigation(
53 input: ThreadRouteInput | PlainThreadRouteInput,
54 terminalId?: string | null,
55): Href {
56 const environmentId = String(input.environmentId);
57 const threadId = String("threadId" in input ? input.threadId : input.id);
58
59 const params: { environmentId: string; threadId: string; terminalId?: string } = {
60 environmentId,
61 threadId,
62 };
63
64 if (terminalId != null && terminalId !== "") {
65 params.terminalId = terminalId;
66 }
67
68 return {
69 pathname: "/threads/[environmentId]/[threadId]/terminal",
70 params,
71 };
72}
73
74export function dismissRoute(router: Router) {
75 if (router.canGoBack()) {
76 router.back();
77 return;
78 }
79
80 router.replace("/");
81}
apps/mobile/src/lib/runtime.ts created+5
...@@ -0,0 +1,5 @@
1import * as ManagedRuntime from "effect/ManagedRuntime";
2
3import { remoteHttpClientLayer } from "@t3tools/client-runtime";
4
5export const mobileRemoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(fetch));
apps/mobile/src/lib/scopedEntities.ts created+16
...@@ -0,0 +1,16 @@
1import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2
3export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string {
4 return `${environmentId}:${projectId}`;
5}
6
7export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId): string {
8 return `${environmentId}:${threadId}`;
9}
10
11export function scopedRequestKey(
12 environmentId: EnvironmentId,
13 requestId: ApprovalRequestId,
14): string {
15 return `${environmentId}:${requestId}`;
16}
apps/mobile/src/lib/storage.ts created+184
...@@ -0,0 +1,184 @@
1import * as Arr from "effect/Array";
2import { pipe } from "effect/Function";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import * as SecureStore from "expo-secure-store";
6import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";
7
8import type { SavedRemoteConnection } from "./connection";
9
10const CONNECTIONS_KEY = "t3code.connections";
11const PREFERENCES_KEY = "t3code.preferences";
12const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
13const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
14
15export interface CachedShellSnapshot {
16 readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION;
17 readonly environmentId: EnvironmentId;
18 readonly snapshotReceivedAt: string;
19 readonly snapshot: OrchestrationShellSnapshot;
20}
21
22export interface MobilePreferences {
23 readonly terminalFontSize?: number;
24}
25
26const CachedShellSnapshotSchema = Schema.Struct({
27 schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
28 environmentId: EnvironmentId,
29 snapshotReceivedAt: Schema.String,
30 snapshot: OrchestrationShellSnapshot,
31});
32const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema);
33
34async function readStorageItem(key: string): Promise<string | null> {
35 return await SecureStore.getItemAsync(key);
36}
37
38async function writeStorageItem(key: string, value: string): Promise<void> {
39 await SecureStore.setItemAsync(key, value);
40}
41
42async function readJsonStorageItem<T>(key: string): Promise<T | null> {
43 const raw = (await readStorageItem(key)) ?? "";
44 if (!raw.trim()) {
45 return null;
46 }
47
48 try {
49 return JSON.parse(raw) as T;
50 } catch {
51 return null;
52 }
53}
54
55function cachedShellSnapshotFileName(environmentId: EnvironmentId): string {
56 return `${encodeURIComponent(environmentId)}.json`;
57}
58
59async function getShellSnapshotCacheDirectory() {
60 const { Directory, Paths } = await import("expo-file-system");
61 const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY);
62 directory.create({ idempotent: true, intermediates: true });
63 return directory;
64}
65
66export async function loadCachedShellSnapshot(
67 environmentId: EnvironmentId,
68): Promise<CachedShellSnapshot | null> {
69 try {
70 const { File } = await import("expo-file-system");
71 const directory = await getShellSnapshotCacheDirectory();
72 const file = new File(directory, cachedShellSnapshotFileName(environmentId));
73 if (!file.exists) {
74 return null;
75 }
76
77 const parsed = JSON.parse(await file.text()) as unknown;
78 const decoded = decodeCachedShellSnapshot(parsed);
79 if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) {
80 return null;
81 }
82
83 return decoded.value;
84 } catch {
85 return null;
86 }
87}
88
89export async function saveCachedShellSnapshot(
90 environmentId: EnvironmentId,
91 snapshot: OrchestrationShellSnapshot,
92): Promise<void> {
93 try {
94 const { File } = await import("expo-file-system");
95 const directory = await getShellSnapshotCacheDirectory();
96 const file = new File(directory, cachedShellSnapshotFileName(environmentId));
97 const document: CachedShellSnapshot = {
98 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
99 environmentId,
100 snapshotReceivedAt: new Date().toISOString(),
101 snapshot,
102 };
103
104 if (!file.exists) {
105 file.create({ intermediates: true, overwrite: true });
106 }
107 file.write(JSON.stringify(document));
108 } catch {
109 // Cache persistence is best-effort and should never block live data.
110 }
111}
112
113export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise<void> {
114 try {
115 const { File } = await import("expo-file-system");
116 const directory = await getShellSnapshotCacheDirectory();
117 const file = new File(directory, cachedShellSnapshotFileName(environmentId));
118 if (file.exists) {
119 file.delete();
120 }
121 } catch {
122 // Ignore cache cleanup failures.
123 }
124}
125
126export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {
127 const parsed = await readJsonStorageItem<{
128 readonly connections?: ReadonlyArray<SavedRemoteConnection>;
129 }>(CONNECTIONS_KEY);
130 if (!parsed) {
131 return [];
132 }
133
134 return pipe(
135 parsed.connections ?? [],
136 Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()),
137 );
138}
139
140export async function saveConnection(connection: SavedRemoteConnection): Promise<void> {
141 const current = await loadSavedConnections();
142 const next = current.some((entry) => entry.environmentId === connection.environmentId)
143 ? pipe(
144 current,
145 Arr.map((entry) => (entry.environmentId === connection.environmentId ? connection : entry)),
146 )
147 : pipe(current, Arr.append(connection));
148
149 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
150}
151
152export async function clearSavedConnection(environmentId: EnvironmentId): Promise<void> {
153 const current = await loadSavedConnections();
154 const next = pipe(
155 current,
156 Arr.filter((entry) => entry.environmentId !== environmentId),
157 );
158 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
159}
160
161export async function loadPreferences(): Promise<MobilePreferences> {
162 const parsed = await readJsonStorageItem<MobilePreferences>(PREFERENCES_KEY);
163 if (!parsed || typeof parsed !== "object") {
164 return {};
165 }
166
167 if (typeof parsed.terminalFontSize === "number") {
168 return { terminalFontSize: parsed.terminalFontSize };
169 }
170
171 return {};
172}
173
174export async function savePreferencesPatch(
175 patch: Partial<MobilePreferences>,
176): Promise<MobilePreferences> {
177 const current = await loadPreferences();
178 const next: MobilePreferences = {
179 ...current,
180 ...patch,
181 };
182 await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next));
183 return next;
184}
apps/mobile/src/lib/threadActivity.test.ts created+172
...@@ -0,0 +1,172 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 EventId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 TurnId,
9 type OrchestrationThread,
10 type OrchestrationThreadActivity,
11} from "@t3tools/contracts";
12
13import { buildThreadFeed } from "./threadActivity";
14
15function makeActivity(
16 input: Partial<OrchestrationThreadActivity> &
17 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
18): OrchestrationThreadActivity {
19 return {
20 tone: "info",
21 payload: {},
22 turnId: null,
23 ...input,
24 };
25}
26
27function makeThread(
28 input: Partial<OrchestrationThread> & Pick<OrchestrationThread, "id" | "projectId" | "title">,
29): OrchestrationThread {
30 return {
31 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
32 runtimeMode: "full-access",
33 interactionMode: "default",
34 branch: null,
35 worktreePath: null,
36 latestTurn: null,
37 createdAt: "2026-04-01T00:00:00.000Z",
38 updatedAt: "2026-04-01T00:00:00.000Z",
39 archivedAt: null,
40 deletedAt: null,
41 messages: [],
42 proposedPlans: [],
43 activities: [],
44 checkpoints: [],
45 session: null,
46 ...input,
47 };
48}
49
50describe("buildThreadFeed", () => {
51 it("includes runtime warnings from the latest turn", () => {
52 const thread = makeThread({
53 id: ThreadId.make("thread-1"),
54 projectId: ProjectId.make("project-1"),
55 title: "Runtime warning thread",
56 latestTurn: {
57 turnId: TurnId.make("turn-latest"),
58 state: "running",
59 requestedAt: "2026-04-01T00:00:00.000Z",
60 startedAt: "2026-04-01T00:00:01.000Z",
61 completedAt: null,
62 assistantMessageId: null,
63 },
64 activities: [
65 makeActivity({
66 id: EventId.make("activity-old"),
67 kind: "runtime.warning",
68 summary: "Runtime warning",
69 createdAt: "2026-04-01T00:00:02.000Z",
70 turnId: TurnId.make("turn-old"),
71 payload: {
72 message: "Old warning",
73 },
74 }),
75 makeActivity({
76 id: EventId.make("activity-latest"),
77 kind: "runtime.warning",
78 summary: "Runtime warning",
79 createdAt: "2026-04-01T00:00:03.000Z",
80 turnId: TurnId.make("turn-latest"),
81 payload: {
82 message: "Latest warning",
83 },
84 }),
85 ],
86 });
87
88 const feed = buildThreadFeed(thread, [], null);
89 const group = feed[0];
90
91 expect(group).toMatchObject({
92 type: "activity-group",
93 });
94 if (!group || group.type !== "activity-group") {
95 return;
96 }
97
98 expect(group.activities).toEqual([
99 {
100 id: "activity-latest",
101 createdAt: "2026-04-01T00:00:03.000Z",
102 summary: "Runtime warning",
103 detail: null,
104 status: null,
105 },
106 ]);
107 });
108
109 it("collapses matching tool lifecycle rows like desktop", () => {
110 const thread = makeThread({
111 id: ThreadId.make("thread-2"),
112 projectId: ProjectId.make("project-1"),
113 title: "Collapsed tools",
114 latestTurn: {
115 turnId: TurnId.make("turn-1"),
116 state: "completed",
117 requestedAt: "2026-04-01T00:00:00.000Z",
118 startedAt: "2026-04-01T00:00:01.000Z",
119 completedAt: "2026-04-01T00:00:03.000Z",
120 assistantMessageId: null,
121 },
122 activities: [
123 makeActivity({
124 id: EventId.make("tool-updated"),
125 kind: "tool.updated",
126 tone: "tool",
127 summary: "Run tests",
128 createdAt: "2026-04-01T00:00:01.000Z",
129 turnId: TurnId.make("turn-1"),
130 payload: {
131 title: "Run tests",
132 itemType: "command_execution",
133 detail: "/bin/zsh -lc 'bun run test'",
134 },
135 }),
136 makeActivity({
137 id: EventId.make("tool-completed"),
138 kind: "tool.completed",
139 tone: "tool",
140 summary: "Run tests completed",
141 createdAt: "2026-04-01T00:00:02.000Z",
142 turnId: TurnId.make("turn-1"),
143 payload: {
144 title: "Run tests",
145 itemType: "command_execution",
146 detail: "/bin/zsh -lc 'bun run test'",
147 },
148 }),
149 ],
150 });
151
152 const feed = buildThreadFeed(thread, [], null);
153 const group = feed[0];
154
155 expect(group).toMatchObject({
156 type: "activity-group",
157 });
158 if (!group || group.type !== "activity-group") {
159 return;
160 }
161
162 expect(group.activities).toEqual([
163 {
164 id: "tool-completed",
165 createdAt: "2026-04-01T00:00:02.000Z",
166 summary: "Run tests",
167 detail: "bun run test",
168 status: null,
169 },
170 ]);
171 });
172});
apps/mobile/src/lib/threadActivity.ts created+942
...@@ -0,0 +1,942 @@
1import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";
2import type {
3 CommandId,
4 EnvironmentId,
5 MessageId,
6 OrchestrationThread,
7 OrchestrationThreadActivity,
8 TurnId,
9 ToolLifecycleItemType,
10 ThreadId,
11 UserInputQuestion,
12} from "@t3tools/contracts";
13
14import type { DraftComposerImageAttachment } from "./composerImages";
15import * as Arr from "effect/Array";
16import * as Order from "effect/Order";
17
18export interface PendingApproval {
19 readonly requestId: ApprovalRequestId;
20 readonly requestKind: "command" | "file-read" | "file-change";
21 readonly createdAt: string;
22 readonly detail?: string;
23}
24
25export interface PendingUserInput {
26 readonly requestId: ApprovalRequestId;
27 readonly createdAt: string;
28 readonly questions: ReadonlyArray<UserInputQuestion>;
29}
30
31export interface PendingUserInputDraftAnswer {
32 readonly selectedOptionLabel?: string;
33 readonly customAnswer?: string;
34}
35
36export interface QueuedThreadMessage {
37 readonly environmentId: EnvironmentId;
38 readonly threadId: ThreadId;
39 readonly messageId: MessageId;
40 readonly commandId: CommandId;
41 readonly text: string;
42 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
43 readonly createdAt: string;
44}
45
46export interface ThreadFeedActivity {
47 readonly id: string;
48 readonly createdAt: string;
49 readonly summary: string;
50 readonly detail: string | null;
51 readonly status: string | null;
52}
53
54interface WorkLogEntry {
55 id: string;
56 createdAt: string;
57 label: string;
58 detail?: string;
59 command?: string;
60 rawCommand?: string;
61 changedFiles?: ReadonlyArray<string>;
62 tone: "thinking" | "tool" | "info" | "error";
63 toolTitle?: string;
64 itemType?: ToolLifecycleItemType;
65 requestKind?: PendingApproval["requestKind"];
66}
67
68interface DerivedWorkLogEntry extends WorkLogEntry {
69 activityKind: OrchestrationThreadActivity["kind"];
70 collapseKey?: string;
71}
72
73type RawThreadFeedEntry =
74 | {
75 readonly type: "message";
76 readonly id: string;
77 readonly createdAt: string;
78 readonly message: OrchestrationThread["messages"][number];
79 }
80 | {
81 readonly type: "queued-message";
82 readonly id: string;
83 readonly createdAt: string;
84 readonly queuedMessage: QueuedThreadMessage;
85 readonly sending: boolean;
86 }
87 | {
88 readonly type: "activity";
89 readonly id: string;
90 readonly createdAt: string;
91 readonly activity: ThreadFeedActivity;
92 };
93
94export type ThreadFeedEntry =
95 | Extract<RawThreadFeedEntry, { type: "message" | "queued-message" }>
96 | {
97 readonly type: "activity-group";
98 readonly id: string;
99 readonly createdAt: string;
100 readonly activities: ReadonlyArray<ThreadFeedActivity>;
101 };
102
103function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
104 switch (requestType) {
105 case "command_execution_approval":
106 case "exec_command_approval":
107 return "command";
108 case "file_read_approval":
109 return "file-read";
110 case "file_change_approval":
111 case "apply_patch_approval":
112 return "file-change";
113 default:
114 return null;
115 }
116}
117
118function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
119 const normalized = detail?.toLowerCase();
120 if (!normalized) {
121 return false;
122 }
123 return (
124 normalized.includes("stale pending approval request") ||
125 normalized.includes("stale pending user-input request") ||
126 normalized.includes("unknown pending approval request") ||
127 normalized.includes("unknown pending permission request") ||
128 normalized.includes("unknown pending user-input request")
129 );
130}
131
132function parseApprovalRequestId(value: unknown): ApprovalRequestId | null {
133 return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null;
134}
135
136function parseUserInputQuestions(
137 payload: Record<string, unknown> | null,
138): ReadonlyArray<UserInputQuestion> | null {
139 const questions = payload?.questions;
140 if (!Array.isArray(questions)) {
141 return null;
142 }
143
144 const parsed = questions
145 .map<UserInputQuestion | null>((entry) => {
146 if (!entry || typeof entry !== "object") return null;
147 const question = entry as Record<string, unknown>;
148 if (
149 typeof question.id !== "string" ||
150 typeof question.header !== "string" ||
151 typeof question.question !== "string" ||
152 !Array.isArray(question.options)
153 ) {
154 return null;
155 }
156 const options = question.options
157 .map<UserInputQuestion["options"][number] | null>((option) => {
158 if (!option || typeof option !== "object") return null;
159 const record = option as Record<string, unknown>;
160 if (typeof record.label !== "string" || typeof record.description !== "string") {
161 return null;
162 }
163 return {
164 label: record.label,
165 description: record.description,
166 };
167 })
168 .filter((option): option is UserInputQuestion["options"][number] => option !== null);
169 if (options.length === 0) {
170 return null;
171 }
172 return {
173 id: question.id,
174 header: question.header,
175 question: question.question,
176 options,
177 multiSelect: question.multiSelect === true,
178 };
179 })
180 .filter((question): question is UserInputQuestion => question !== null);
181
182 return parsed.length > 0 ? parsed : null;
183}
184
185function normalizeDraftAnswer(value: string | undefined): string | null {
186 if (typeof value !== "string") {
187 return null;
188 }
189 const trimmed = value.trim();
190 return trimmed.length > 0 ? trimmed : null;
191}
192
193function resolvePendingUserInputAnswer(
194 draft: PendingUserInputDraftAnswer | undefined,
195): string | null {
196 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
197 if (customAnswer) {
198 return customAnswer;
199 }
200 return normalizeDraftAnswer(draft?.selectedOptionLabel);
201}
202
203function deriveWorkLogEntries(
204 activities: ReadonlyArray<OrchestrationThreadActivity>,
205 latestTurnId: TurnId | undefined,
206): WorkLogEntry[] {
207 const ordered = Arr.sort(activities, activityOrder);
208 const entries: DerivedWorkLogEntry[] = [];
209 for (const activity of ordered) {
210 if (latestTurnId && activity.turnId !== latestTurnId) continue;
211 if (activity.kind === "tool.started") continue;
212 if (activity.kind === "task.started" || activity.kind === "task.completed") continue;
213 if (activity.kind === "context-window.updated") continue;
214 if (activity.summary === "Checkpoint captured") continue;
215 if (isPlanBoundaryToolActivity(activity)) continue;
216 entries.push(toDerivedWorkLogEntry(activity));
217 }
218 return collapseDerivedWorkLogEntries(entries).map(
219 ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry,
220 );
221}
222
223function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
224 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
225 return false;
226 }
227
228 const payload =
229 activity.payload && typeof activity.payload === "object"
230 ? (activity.payload as Record<string, unknown>)
231 : null;
232 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
233}
234
235function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
236 const payload =
237 activity.payload && typeof activity.payload === "object"
238 ? (activity.payload as Record<string, unknown>)
239 : null;
240 const commandPreview = extractToolCommand(payload);
241 const changedFiles = extractChangedFiles(payload);
242 const title = extractToolTitle(payload);
243 const entry: DerivedWorkLogEntry = {
244 id: activity.id,
245 createdAt: activity.createdAt,
246 label: activity.summary,
247 tone: activity.tone === "approval" ? "info" : activity.tone,
248 activityKind: activity.kind,
249 };
250 const itemType = extractWorkLogItemType(payload);
251 const requestKind = extractWorkLogRequestKind(payload);
252 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
253 const detail = stripTrailingExitCode(payload.detail).output;
254 if (detail) {
255 entry.detail = detail;
256 }
257 }
258 if (commandPreview.command) {
259 entry.command = commandPreview.command;
260 }
261 if (commandPreview.rawCommand) {
262 entry.rawCommand = commandPreview.rawCommand;
263 }
264 if (changedFiles.length > 0) {
265 entry.changedFiles = changedFiles;
266 }
267 if (title) {
268 entry.toolTitle = title;
269 }
270 if (itemType) {
271 entry.itemType = itemType;
272 }
273 if (requestKind) {
274 entry.requestKind = requestKind;
275 }
276 const collapseKey = deriveToolLifecycleCollapseKey(entry);
277 if (collapseKey) {
278 entry.collapseKey = collapseKey;
279 }
280 return entry;
281}
282
283function collapseDerivedWorkLogEntries(
284 entries: ReadonlyArray<DerivedWorkLogEntry>,
285): DerivedWorkLogEntry[] {
286 const collapsed: DerivedWorkLogEntry[] = [];
287 for (const entry of entries) {
288 const previous = collapsed.at(-1);
289 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
290 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
291 continue;
292 }
293 collapsed.push(entry);
294 }
295 return collapsed;
296}
297
298function shouldCollapseToolLifecycleEntries(
299 previous: DerivedWorkLogEntry,
300 next: DerivedWorkLogEntry,
301): boolean {
302 if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
303 return false;
304 }
305 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
306 return false;
307 }
308 if (previous.activityKind === "tool.completed") {
309 return false;
310 }
311 return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey;
312}
313
314function mergeDerivedWorkLogEntries(
315 previous: DerivedWorkLogEntry,
316 next: DerivedWorkLogEntry,
317): DerivedWorkLogEntry {
318 const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
319 const detail = next.detail ?? previous.detail;
320 const command = next.command ?? previous.command;
321 const rawCommand = next.rawCommand ?? previous.rawCommand;
322 const toolTitle = next.toolTitle ?? previous.toolTitle;
323 const itemType = next.itemType ?? previous.itemType;
324 const requestKind = next.requestKind ?? previous.requestKind;
325 const collapseKey = next.collapseKey ?? previous.collapseKey;
326 return {
327 ...previous,
328 ...next,
329 ...(detail ? { detail } : {}),
330 ...(command ? { command } : {}),
331 ...(rawCommand ? { rawCommand } : {}),
332 ...(changedFiles.length > 0 ? { changedFiles } : {}),
333 ...(toolTitle ? { toolTitle } : {}),
334 ...(itemType ? { itemType } : {}),
335 ...(requestKind ? { requestKind } : {}),
336 ...(collapseKey ? { collapseKey } : {}),
337 };
338}
339
340function mergeChangedFiles(
341 previous: ReadonlyArray<string> | undefined,
342 next: ReadonlyArray<string> | undefined,
343): string[] {
344 const merged = [...(previous ?? []), ...(next ?? [])];
345 if (merged.length === 0) {
346 return [];
347 }
348 return [...new Set(merged)];
349}
350
351function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {
352 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
353 return undefined;
354 }
355 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
356 const detail = entry.detail?.trim() ?? "";
357 const itemType = entry.itemType ?? "";
358 if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) {
359 return undefined;
360 }
361 return [itemType, normalizedLabel, detail].join("\u001f");
362}
363
364function normalizeCompactToolLabel(value: string): string {
365 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
366}
367
368function workEntryPreview(
369 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
370): string | null {
371 if (workEntry.command) return workEntry.command;
372 if (workEntry.detail) return workEntry.detail;
373 if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
374 const [firstPath] = workEntry.changedFiles ?? [];
375 if (!firstPath) return null;
376 return workEntry.changedFiles!.length === 1
377 ? firstPath
378 : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
379}
380
381function capitalizePhrase(value: string): string {
382 const trimmed = value.trim();
383 if (trimmed.length === 0) {
384 return value;
385 }
386 return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
387}
388
389function workEntryHeading(workEntry: WorkLogEntry): string {
390 if (!workEntry.toolTitle) {
391 return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
392 }
393 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
394}
395
396function asRecord(value: unknown): Record<string, unknown> | null {
397 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
398}
399
400function asTrimmedString(value: unknown): string | null {
401 if (typeof value !== "string") {
402 return null;
403 }
404 const trimmed = value.trim();
405 return trimmed.length > 0 ? trimmed : null;
406}
407
408function trimMatchingOuterQuotes(value: string): string {
409 const trimmed = value.trim();
410 if (
411 (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
412 (trimmed.startsWith('"') && trimmed.endsWith('"'))
413 ) {
414 const unquoted = trimmed.slice(1, -1).trim();
415 return unquoted.length > 0 ? unquoted : trimmed;
416 }
417 return trimmed;
418}
419
420function executableBasename(value: string): string | null {
421 const trimmed = trimMatchingOuterQuotes(value);
422 if (trimmed.length === 0) {
423 return null;
424 }
425 const normalized = trimmed.replace(/\\/g, "/");
426 const segments = normalized.split("/");
427 const last = segments.at(-1)?.trim() ?? "";
428 return last.length > 0 ? last.toLowerCase() : null;
429}
430
431function splitExecutableAndRest(value: string): { executable: string; rest: string } | null {
432 const trimmed = value.trim();
433 if (trimmed.length === 0) {
434 return null;
435 }
436
437 if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
438 const quote = trimmed.charAt(0);
439 const closeIndex = trimmed.indexOf(quote, 1);
440 if (closeIndex <= 0) {
441 return null;
442 }
443 return {
444 executable: trimmed.slice(0, closeIndex + 1),
445 rest: trimmed.slice(closeIndex + 1).trim(),
446 };
447 }
448
449 const firstWhitespace = trimmed.search(/\s/);
450 if (firstWhitespace < 0) {
451 return {
452 executable: trimmed,
453 rest: "",
454 };
455 }
456
457 return {
458 executable: trimmed.slice(0, firstWhitespace),
459 rest: trimmed.slice(firstWhitespace).trim(),
460 };
461}
462
463const SHELL_WRAPPER_SPECS = [
464 {
465 executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"],
466 wrapperFlagPattern: /(?:^|\s)-command\s+/i,
467 },
468 {
469 executables: ["cmd", "cmd.exe"],
470 wrapperFlagPattern: /(?:^|\s)\/c\s+/i,
471 },
472 {
473 executables: ["bash", "sh", "zsh"],
474 wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i,
475 },
476] as const;
477
478function findShellWrapperSpec(shell: string) {
479 return SHELL_WRAPPER_SPECS.find((spec) =>
480 (spec.executables as ReadonlyArray<string>).includes(shell),
481 );
482}
483
484function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null {
485 const match = wrapperFlagPattern.exec(value);
486 if (!match) {
487 return null;
488 }
489
490 const command = value.slice(match.index + match[0].length).trim();
491 if (command.length === 0) {
492 return null;
493 }
494
495 const unwrapped = trimMatchingOuterQuotes(command);
496 return unwrapped.length > 0 ? unwrapped : null;
497}
498
499function unwrapKnownShellCommandWrapper(value: string): string {
500 const split = splitExecutableAndRest(value);
501 if (!split || split.rest.length === 0) {
502 return value;
503 }
504
505 const shell = executableBasename(split.executable);
506 if (!shell) {
507 return value;
508 }
509
510 const spec = findShellWrapperSpec(shell);
511 if (!spec) {
512 return value;
513 }
514
515 return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value;
516}
517
518function formatCommandArrayPart(value: string): string {
519 return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
520}
521
522function formatCommandValue(value: unknown): string | null {
523 const direct = asTrimmedString(value);
524 if (direct) {
525 return direct;
526 }
527 if (!Array.isArray(value)) {
528 return null;
529 }
530 const parts: Array<string> = [];
531 for (const entry of value) {
532 const part = asTrimmedString(entry);
533 if (part !== null) {
534 parts.push(part);
535 }
536 }
537 if (parts.length === 0) {
538 return null;
539 }
540 return parts.map((part) => formatCommandArrayPart(part)).join(" ");
541}
542
543function normalizeCommandValue(value: unknown): string | null {
544 const formatted = formatCommandValue(value);
545 return formatted ? unwrapKnownShellCommandWrapper(formatted) : null;
546}
547
548function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null {
549 const formatted = formatCommandValue(value);
550 if (!formatted || normalizedCommand === null) {
551 return null;
552 }
553 return formatted === normalizedCommand ? null : formatted;
554}
555
556function extractToolCommand(payload: Record<string, unknown> | null): {
557 command: string | null;
558 rawCommand: string | null;
559} {
560 const data = asRecord(payload?.data);
561 const item = asRecord(data?.item);
562 const itemResult = asRecord(item?.result);
563 const itemInput = asRecord(item?.input);
564 const itemType = asTrimmedString(payload?.itemType);
565 const detail = asTrimmedString(payload?.detail);
566 const candidates: unknown[] = [
567 item?.command,
568 itemInput?.command,
569 itemResult?.command,
570 data?.command,
571 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
572 ];
573
574 for (const candidate of candidates) {
575 const command = normalizeCommandValue(candidate);
576 if (!command) {
577 continue;
578 }
579 return {
580 command,
581 rawCommand: toRawToolCommand(candidate, command),
582 };
583 }
584
585 return {
586 command: null,
587 rawCommand: null,
588 };
589}
590
591function extractToolTitle(payload: Record<string, unknown> | null): string | null {
592 return asTrimmedString(payload?.title);
593}
594
595function stripTrailingExitCode(value: string): {
596 output: string | null;
597 exitCode?: number | undefined;
598} {
599 const trimmed = value.trim();
600 const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code (?<code>\d+)>)\s*$/i.exec(
601 trimmed,
602 );
603 if (!match?.groups) {
604 return {
605 output: trimmed.length > 0 ? trimmed : null,
606 };
607 }
608 const exitCode = Number.parseInt(match.groups.code ?? "", 10);
609 const normalizedOutput = match.groups.output?.trim() ?? "";
610 return {
611 output: normalizedOutput.length > 0 ? normalizedOutput : null,
612 ...(Number.isInteger(exitCode) ? { exitCode } : {}),
613 };
614}
615
616function extractWorkLogItemType(
617 payload: Record<string, unknown> | null,
618): WorkLogEntry["itemType"] | undefined {
619 if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) {
620 return payload.itemType;
621 }
622 return undefined;
623}
624
625function extractWorkLogRequestKind(
626 payload: Record<string, unknown> | null,
627): WorkLogEntry["requestKind"] | undefined {
628 if (
629 payload?.requestKind === "command" ||
630 payload?.requestKind === "file-read" ||
631 payload?.requestKind === "file-change"
632 ) {
633 return payload.requestKind;
634 }
635 return requestKindFromRequestType(payload?.requestType) ?? undefined;
636}
637
638function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {
639 const normalized = asTrimmedString(value);
640 if (!normalized || seen.has(normalized)) {
641 return;
642 }
643 seen.add(normalized);
644 target.push(normalized);
645}
646
647function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) {
648 if (depth > 4 || target.length >= 12) {
649 return;
650 }
651 if (Array.isArray(value)) {
652 for (const entry of value) {
653 collectChangedFiles(entry, target, seen, depth + 1);
654 if (target.length >= 12) {
655 return;
656 }
657 }
658 return;
659 }
660
661 const record = asRecord(value);
662 if (!record) {
663 return;
664 }
665
666 pushChangedFile(target, seen, record.path);
667 pushChangedFile(target, seen, record.filePath);
668 pushChangedFile(target, seen, record.relativePath);
669 pushChangedFile(target, seen, record.filename);
670 pushChangedFile(target, seen, record.newPath);
671 pushChangedFile(target, seen, record.oldPath);
672
673 for (const nestedKey of [
674 "item",
675 "result",
676 "input",
677 "data",
678 "changes",
679 "files",
680 "edits",
681 "patch",
682 "patches",
683 "operations",
684 ]) {
685 if (!(nestedKey in record)) {
686 continue;
687 }
688 collectChangedFiles(record[nestedKey], target, seen, depth + 1);
689 if (target.length >= 12) {
690 return;
691 }
692 }
693}
694
695function extractChangedFiles(payload: Record<string, unknown> | null): string[] {
696 const changedFiles: string[] = [];
697 const seen = new Set<string>();
698 collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0);
699 return changedFiles;
700}
701
702function compareActivityLifecycleRank(kind: string): number {
703 if (kind.endsWith(".started") || kind === "tool.started") {
704 return 0;
705 }
706 if (kind.endsWith(".progress") || kind.endsWith(".updated")) {
707 return 1;
708 }
709 if (kind.endsWith(".completed") || kind.endsWith(".resolved")) {
710 return 2;
711 }
712 return 1;
713}
714
715const activityOrder = Order.combineAll<OrchestrationThreadActivity>([
716 Order.mapInput(Order.Number, (activity) => activity.sequence ?? Number.MAX_SAFE_INTEGER),
717 Order.mapInput(Order.String, (activity) => activity.createdAt),
718 Order.mapInput(Order.Number, (activity) => compareActivityLifecycleRank(activity.kind)),
719 Order.mapInput(Order.String, (activity) => activity.id),
720]);
721
722function isEmptyMessage(entry: RawThreadFeedEntry): boolean {
723 if (entry.type !== "message") {
724 return false;
725 }
726 const hasText = entry.message.text.trim().length > 0;
727 const hasAttachments = (entry.message.attachments ?? []).length > 0;
728 return !hasText && !hasAttachments;
729}
730
731function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {
732 const grouped: ThreadFeedEntry[] = [];
733
734 for (const entry of entries) {
735 // Skip empty messages so they don't break activity grouping.
736 if (isEmptyMessage(entry)) {
737 continue;
738 }
739
740 if (entry.type !== "activity") {
741 grouped.push(entry);
742 continue;
743 }
744
745 const previous = grouped.at(-1);
746 if (previous?.type === "activity-group") {
747 grouped[grouped.length - 1] = {
748 ...previous,
749 activities: [...previous.activities, entry.activity],
750 };
751 continue;
752 }
753
754 grouped.push({
755 type: "activity-group",
756 id: entry.id,
757 createdAt: entry.createdAt,
758 activities: [entry.activity],
759 });
760 }
761
762 return grouped;
763}
764
765export function derivePendingApprovals(
766 activities: ReadonlyArray<OrchestrationThreadActivity>,
767): PendingApproval[] {
768 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
769 const ordered = Arr.sort(activities, activityOrder);
770
771 for (const activity of ordered) {
772 const payload =
773 activity.payload && typeof activity.payload === "object"
774 ? (activity.payload as Record<string, unknown>)
775 : null;
776 const requestId = parseApprovalRequestId(payload?.requestId);
777 const requestKind =
778 payload?.requestKind === "command" ||
779 payload?.requestKind === "file-read" ||
780 payload?.requestKind === "file-change"
781 ? payload.requestKind
782 : requestKindFromRequestType(payload?.requestType);
783 const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
784
785 if (activity.kind === "approval.requested" && requestId && requestKind) {
786 openByRequestId.set(requestId, {
787 requestId,
788 requestKind,
789 createdAt: activity.createdAt,
790 ...(detail ? { detail } : {}),
791 });
792 continue;
793 }
794
795 if (activity.kind === "approval.resolved" && requestId) {
796 openByRequestId.delete(requestId);
797 continue;
798 }
799
800 if (
801 activity.kind === "provider.approval.respond.failed" &&
802 requestId &&
803 isStalePendingRequestFailureDetail(detail)
804 ) {
805 openByRequestId.delete(requestId);
806 }
807 }
808
809 return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date);
810}
811
812export function derivePendingUserInputs(
813 activities: ReadonlyArray<OrchestrationThreadActivity>,
814): PendingUserInput[] {
815 const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();
816 const ordered = Arr.sort(activities, activityOrder);
817
818 for (const activity of ordered) {
819 const payload =
820 activity.payload && typeof activity.payload === "object"
821 ? (activity.payload as Record<string, unknown>)
822 : null;
823 const requestId = parseApprovalRequestId(payload?.requestId);
824 const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
825
826 if (activity.kind === "user-input.requested" && requestId) {
827 const questions = parseUserInputQuestions(payload);
828 if (!questions) {
829 continue;
830 }
831 openByRequestId.set(requestId, {
832 requestId,
833 createdAt: activity.createdAt,
834 questions,
835 });
836 continue;
837 }
838
839 if (activity.kind === "user-input.resolved" && requestId) {
840 openByRequestId.delete(requestId);
841 continue;
842 }
843
844 if (
845 activity.kind === "provider.user-input.respond.failed" &&
846 requestId &&
847 isStalePendingRequestFailureDetail(detail)
848 ) {
849 openByRequestId.delete(requestId);
850 }
851 }
852
853 return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date);
854}
855
856export function setPendingUserInputCustomAnswer(
857 draft: PendingUserInputDraftAnswer | undefined,
858 customAnswer: string,
859): PendingUserInputDraftAnswer {
860 const selectedOptionLabel =
861 customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel;
862 return {
863 customAnswer,
864 ...(selectedOptionLabel ? { selectedOptionLabel } : {}),
865 };
866}
867
868export function buildPendingUserInputAnswers(
869 questions: ReadonlyArray<UserInputQuestion>,
870 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
871): Record<string, string> | null {
872 const answers: Record<string, string> = {};
873
874 for (const question of questions) {
875 const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);
876 if (!answer) {
877 return null;
878 }
879 answers[question.id] = answer;
880 }
881
882 return answers;
883}
884
885export function buildThreadFeed(
886 thread: OrchestrationThread,
887 queuedMessages: ReadonlyArray<QueuedThreadMessage>,
888 dispatchingQueuedMessageId: MessageId | null,
889 options?: {
890 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
891 },
892): ThreadFeedEntry[] {
893 const loadedMessages = options?.loadedMessages ?? thread.messages;
894 const oldestLoadedMessageCreatedAt =
895 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;
896 const workLogEntries = deriveWorkLogEntries(
897 thread.activities,
898 thread.latestTurn?.turnId ?? undefined,
899 );
900 const entries = Arr.sortWith(
901 [
902 ...loadedMessages.map<RawThreadFeedEntry>((message) => ({
903 type: "message",
904 id: message.id,
905 createdAt: message.createdAt,
906 message,
907 })),
908 ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({
909 type: "queued-message",
910 id: queuedMessage.messageId,
911 createdAt: queuedMessage.createdAt,
912 queuedMessage,
913 sending: queuedMessage.messageId === dispatchingQueuedMessageId,
914 })),
915 ...workLogEntries
916 .filter((entry) => {
917 if (options?.loadedMessages === undefined) {
918 return true;
919 }
920 return (
921 oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt
922 );
923 })
924 .map<RawThreadFeedEntry>((entry) => ({
925 type: "activity",
926 id: entry.id,
927 createdAt: entry.createdAt,
928 activity: {
929 id: entry.id,
930 createdAt: entry.createdAt,
931 summary: workEntryHeading(entry),
932 detail: workEntryPreview(entry),
933 status: null,
934 },
935 })),
936 ],
937 (s) => new Date(s.createdAt),
938 Order.Date,
939 );
940
941 return groupAdjacentActivities(entries);
942}
apps/mobile/src/lib/time.ts created+19
...@@ -0,0 +1,19 @@
1export function relativeTime(input: string): string {
2 const timestamp = Date.parse(input);
3 if (Number.isNaN(timestamp)) {
4 return "now";
5 }
6
7 const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
8 if (deltaSeconds < 10) return "now";
9 if (deltaSeconds < 60) return `${deltaSeconds}s`;
10
11 const deltaMinutes = Math.floor(deltaSeconds / 60);
12 if (deltaMinutes < 60) return `${deltaMinutes}m`;
13
14 const deltaHours = Math.floor(deltaMinutes / 60);
15 if (deltaHours < 24) return `${deltaHours}h`;
16
17 const deltaDays = Math.floor(deltaHours / 24);
18 return `${deltaDays}d`;
19}
apps/mobile/src/lib/useNativePaste.ts created+21
...@@ -0,0 +1,21 @@
1import type { PasteEventPayload } from "expo-paste-input";
2import { useCallback } from "react";
3
4/**
5 * Returns a stable `onPaste` handler for `TextInputWrapper` from
6 * `expo-paste-input`. When the user pastes images via the OS paste gesture,
7 * `onImages` is called with the pasted URIs. Text pastes are left to the
8 * native TextInput — no extra handling is needed.
9 *
10 * Used by both the thread composer and the new-task draft screen.
11 */
12export function useNativePaste(onImages: (uris: ReadonlyArray<string>) => void) {
13 return useCallback(
14 (payload: PasteEventPayload) => {
15 if (payload.type === "images" && payload.uris && payload.uris.length > 0) {
16 onImages(payload.uris);
17 }
18 },
19 [onImages],
20 );
21}
apps/mobile/src/lib/useThemeColor.ts created+12
...@@ -0,0 +1,12 @@
1import type { ColorValue } from "react-native";
2import { useCSSVariable } from "uniwind";
3
4/**
5 * Typed wrapper around `useCSSVariable` that returns a `ColorValue` for use
6 * in React Native style props (backgroundColor, tintColor, etc.).
7 *
8 * Usage: `const color = useThemeColor("--color-icon");`
9 */
10export function useThemeColor(variable: `--color-${string}`): ColorValue {
11 return useCSSVariable(variable) as string as ColorValue;
12}
apps/mobile/src/lib/uuid.ts created+3
...@@ -0,0 +1,3 @@
1import * as Crypto from "expo-crypto";
2
3export const uuidv4 = () => Crypto.randomUUID();
apps/mobile/src/state/atom-registry.ts created+3
...@@ -0,0 +1,3 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2
3export const appAtomRegistry = AtomRegistry.make();
apps/mobile/src/state/environment-session-registry.ts created+48
...@@ -0,0 +1,48 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3import type { EnvironmentSession } from "./remote-runtime-types";
4
5const environmentSessions = new Map<EnvironmentId, EnvironmentSession>();
6const environmentConnectionListeners = new Set<() => void>();
7
8export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null {
9 return environmentSessions.get(environmentId) ?? null;
10}
11
12export function getEnvironmentClient(environmentId: EnvironmentId) {
13 return getEnvironmentSession(environmentId)?.client ?? null;
14}
15
16export function setEnvironmentSession(
17 environmentId: EnvironmentId,
18 session: EnvironmentSession,
19): void {
20 environmentSessions.set(environmentId, session);
21}
22
23export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null {
24 const session = getEnvironmentSession(environmentId);
25 environmentSessions.delete(environmentId);
26 return session;
27}
28
29export function drainEnvironmentSessions(): ReadonlyArray<EnvironmentSession> {
30 const sessions = [...environmentSessions.values()];
31 environmentSessions.clear();
32 return sessions;
33}
34
35export function notifyEnvironmentConnectionListeners() {
36 for (const listener of environmentConnectionListeners) listener();
37}
38
39/**
40 * Subscribe to environment-connection changes (connect / disconnect / reconnect).
41 * Returns an unsubscribe function.
42 */
43export function subscribeEnvironmentConnections(listener: () => void): () => void {
44 environmentConnectionListeners.add(listener);
45 return () => {
46 environmentConnectionListeners.delete(listener);
47 };
48}
apps/mobile/src/state/remote-runtime-types.ts created+26
...@@ -0,0 +1,26 @@
1import type {
2 EnvironmentConnection,
3 EnvironmentConnectionState,
4 WsRpcClient,
5} from "@t3tools/client-runtime";
6import { EnvironmentId, ThreadId } from "@t3tools/contracts";
7
8export type { EnvironmentRuntimeState } from "@t3tools/client-runtime";
9
10export interface ConnectedEnvironmentSummary {
11 readonly environmentId: EnvironmentId;
12 readonly environmentLabel: string;
13 readonly displayUrl: string;
14 readonly connectionState: EnvironmentConnectionState;
15 readonly connectionError: string | null;
16}
17
18export interface SelectedThreadRef {
19 readonly environmentId: EnvironmentId;
20 readonly threadId: ThreadId;
21}
22
23export interface EnvironmentSession {
24 readonly client: WsRpcClient;
25 readonly connection: EnvironmentConnection;
26}
apps/mobile/src/state/use-checkpoint-diff.ts created+16
...@@ -0,0 +1,16 @@
1import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime";
2
3import { appAtomRegistry } from "./atom-registry";
4import { getEnvironmentClient } from "./environment-session-registry";
5
6export const checkpointDiffManager = createCheckpointDiffManager({
7 getRegistry: () => appAtomRegistry,
8 getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null,
9});
10
11export function loadCheckpointDiff(
12 target: CheckpointDiffTarget,
13 options?: { readonly force?: boolean },
14) {
15 return checkpointDiffManager.load(target, undefined, options);
16}
apps/mobile/src/state/use-composer-path-search.ts created+46
...@@ -0,0 +1,46 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type ComposerPathSearchState,
4 type ComposerPathSearchTarget,
5 EMPTY_COMPOSER_PATH_SEARCH_ATOM,
6 EMPTY_COMPOSER_PATH_SEARCH_STATE,
7 composerPathSearchStateAtom,
8 createComposerPathSearchManager,
9 getComposerPathSearchTargetKey,
10 normalizeComposerPathSearchQuery,
11} from "@t3tools/client-runtime";
12import { useEffect, useMemo } from "react";
13
14import { appAtomRegistry } from "./atom-registry";
15import {
16 getEnvironmentClient,
17 subscribeEnvironmentConnections,
18} from "./environment-session-registry";
19
20const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000;
21
22const composerPathSearchManager = createComposerPathSearchManager({
23 getRegistry: () => appAtomRegistry,
24 getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null,
25 subscribeClientChanges: subscribeEnvironmentConnections,
26 staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS,
27});
28
29export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState {
30 const stableTarget = useMemo(
31 () => ({
32 environmentId: target.environmentId,
33 cwd: target.cwd,
34 query: normalizeComposerPathSearchQuery(target.query),
35 }),
36 [target.cwd, target.environmentId, target.query],
37 );
38 const targetKey = getComposerPathSearchTargetKey(stableTarget);
39
40 useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]);
41
42 const state = useAtomValue(
43 targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM,
44 );
45 return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state;
46}
apps/mobile/src/state/use-environment-runtime.ts created+76
...@@ -0,0 +1,76 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 EMPTY_ENVIRONMENT_RUNTIME_ATOM,
4 EMPTY_ENVIRONMENT_RUNTIME_STATE,
5 createEnvironmentRuntimeManager,
6 environmentRuntimeStateAtom,
7 getEnvironmentRuntimeTargetKey,
8 type EnvironmentRuntimeState,
9} from "@t3tools/client-runtime";
10import type { EnvironmentId } from "@t3tools/contracts";
11import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
12
13import { appAtomRegistry } from "./atom-registry";
14import * as Arr from "effect/Array";
15import * as Order from "effect/Order";
16
17export const environmentRuntimeManager = createEnvironmentRuntimeManager({
18 getRegistry: () => appAtomRegistry,
19});
20
21export function useEnvironmentRuntime(
22 environmentId: EnvironmentId | null,
23): EnvironmentRuntimeState {
24 const targetKey = getEnvironmentRuntimeTargetKey({ environmentId });
25 const state = useAtomValue(
26 targetKey !== null ? environmentRuntimeStateAtom(targetKey) : EMPTY_ENVIRONMENT_RUNTIME_ATOM,
27 );
28 return targetKey === null ? EMPTY_ENVIRONMENT_RUNTIME_STATE : state;
29}
30
31export function useEnvironmentRuntimeStates(
32 environmentIds: ReadonlyArray<EnvironmentId>,
33): Readonly<Record<EnvironmentId, EnvironmentRuntimeState>> {
34 const stableEnvironmentIds = useMemo(
35 () => Arr.sort(new Set(environmentIds), Order.String),
36 [environmentIds],
37 );
38 const snapshotCacheRef = useRef<Readonly<Record<EnvironmentId, EnvironmentRuntimeState>>>({});
39
40 const subscribe = useCallback(
41 (onStoreChange: () => void) => {
42 const unsubs = stableEnvironmentIds.map((environmentId) =>
43 appAtomRegistry.subscribe(environmentRuntimeStateAtom(environmentId), onStoreChange),
44 );
45 return () => {
46 for (const unsub of unsubs) {
47 unsub();
48 }
49 };
50 },
51 [stableEnvironmentIds],
52 );
53
54 const getSnapshot = useCallback(() => {
55 const previous = snapshotCacheRef.current;
56 let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length;
57 const next: Record<EnvironmentId, EnvironmentRuntimeState> = {};
58
59 for (const environmentId of stableEnvironmentIds) {
60 const snapshot = environmentRuntimeManager.getSnapshot({ environmentId });
61 next[environmentId] = snapshot;
62 if (!hasChanged && previous[environmentId] !== snapshot) {
63 hasChanged = true;
64 }
65 }
66
67 if (!hasChanged) {
68 return previous;
69 }
70
71 snapshotCacheRef.current = next;
72 return next;
73 }, [stableEnvironmentIds]);
74
75 return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
76}
apps/mobile/src/state/use-filesystem-browse.ts created+82
...@@ -0,0 +1,82 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 EMPTY_FILESYSTEM_BROWSE_ATOM,
4 EMPTY_FILESYSTEM_BROWSE_STATE,
5 type FilesystemBrowseClient,
6 type FilesystemBrowseState,
7 type FilesystemBrowseTarget,
8 createFilesystemBrowseManager,
9 filesystemBrowseStateAtom,
10 getFilesystemBrowseTargetKey,
11} from "@t3tools/client-runtime";
12import type {
13 EnvironmentId,
14 FilesystemBrowseInput,
15 FilesystemBrowseResult,
16} from "@t3tools/contracts";
17import { useEffect, useMemo } from "react";
18
19import { appAtomRegistry } from "./atom-registry";
20import {
21 getEnvironmentClient,
22 subscribeEnvironmentConnections,
23} from "./environment-session-registry";
24
25const filesystemBrowseManager = createFilesystemBrowseManager<EnvironmentId>({
26 getRegistry: () => appAtomRegistry,
27 getClient: (environmentId) => getEnvironmentClient(environmentId)?.filesystem ?? null,
28 subscribeClientChanges: subscribeEnvironmentConnections,
29});
30
31function filesystemBrowseTargetForEnvironment(
32 environmentId: EnvironmentId | null,
33 input: FilesystemBrowseInput | null,
34): FilesystemBrowseTarget<EnvironmentId> {
35 return { key: environmentId, input };
36}
37
38export function refreshFilesystemBrowseForEnvironment(
39 environmentId: EnvironmentId | null,
40 input: FilesystemBrowseInput | null,
41 client?: FilesystemBrowseClient | null,
42): Promise<FilesystemBrowseResult | null> {
43 return filesystemBrowseManager.refresh(
44 filesystemBrowseTargetForEnvironment(environmentId, input),
45 client ?? undefined,
46 );
47}
48
49export function invalidateFilesystemBrowseForEnvironment(
50 environmentId: EnvironmentId | null,
51 input: FilesystemBrowseInput | null,
52): void {
53 filesystemBrowseManager.invalidate(filesystemBrowseTargetForEnvironment(environmentId, input));
54}
55
56export function resetFilesystemBrowseState(): void {
57 filesystemBrowseManager.reset();
58}
59
60export function resetFilesystemBrowseStateForTests(): void {
61 resetFilesystemBrowseState();
62}
63
64export function useFilesystemBrowse(
65 environmentId: EnvironmentId | null,
66 input: FilesystemBrowseInput | null,
67): FilesystemBrowseState {
68 const target = useMemo(
69 () => filesystemBrowseTargetForEnvironment(environmentId, input),
70 [environmentId, input],
71 );
72
73 useEffect(() => {
74 return filesystemBrowseManager.watch(target);
75 }, [target]);
76
77 const targetKey = getFilesystemBrowseTargetKey(target);
78 const state = useAtomValue(
79 targetKey !== null ? filesystemBrowseStateAtom(targetKey) : EMPTY_FILESYSTEM_BROWSE_ATOM,
80 );
81 return targetKey === null ? EMPTY_FILESYSTEM_BROWSE_STATE : state;
82}
apps/mobile/src/state/use-remote-catalog.ts created+198
...@@ -0,0 +1,198 @@
1import { useMemo } from "react";
2import * as Order from "effect/Order";
3import * as Arr from "effect/Array";
4
5import {
6 EnvironmentConnectionState,
7 EnvironmentScopedProjectShell,
8 EnvironmentScopedThreadShell,
9 scopeProjectShell,
10 scopeThreadShell,
11} from "@t3tools/client-runtime";
12
13import { ConnectedEnvironmentSummary } from "./remote-runtime-types";
14import type { SavedRemoteConnection } from "../lib/connection";
15import { useCachedShellSnapshotMetadata, useShellSnapshotStates } from "./use-shell-snapshot";
16import {
17 useRemoteConnectionStatus,
18 useRemoteEnvironmentState,
19} from "./use-remote-environment-registry";
20
21const projectsSortOrder = Order.mapInput(
22 Order.Struct({
23 title: Order.String,
24 environmentId: Order.String,
25 }),
26 (project: EnvironmentScopedProjectShell) => ({
27 title: project.title,
28 environmentId: project.environmentId,
29 }),
30);
31
32const threadsSortOrder = Order.mapInput(
33 Order.Struct({
34 activityAt: Order.flip(Order.String),
35 environmentId: Order.String,
36 }),
37 (thread: EnvironmentScopedThreadShell) => ({
38 activityAt: thread.updatedAt ?? thread.createdAt,
39 environmentId: thread.environmentId,
40 }),
41);
42
43function deriveOverallConnectionState(
44 environments: ReadonlyArray<ConnectedEnvironmentSummary>,
45): EnvironmentConnectionState {
46 if (environments.length === 0) {
47 return "idle";
48 }
49 if (environments.some((environment) => environment.connectionState === "ready")) {
50 return "ready";
51 }
52 if (environments.some((environment) => environment.connectionState === "reconnecting")) {
53 return "reconnecting";
54 }
55 if (environments.some((environment) => environment.connectionState === "connecting")) {
56 return "connecting";
57 }
58 return "disconnected";
59}
60
61function listRemoteCatalogEnvironmentIds(
62 savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>,
63): ReadonlyArray<SavedRemoteConnection["environmentId"]> {
64 const environmentIds: SavedRemoteConnection["environmentId"][] = [];
65 for (const connection of Object.values(savedConnectionsById)) {
66 environmentIds.push(connection.environmentId);
67 }
68 return environmentIds;
69}
70
71export interface RemoteCatalogState {
72 readonly isLoadingSavedConnections: boolean;
73 readonly hasSavedConnections: boolean;
74 readonly hasLoadedShellSnapshot: boolean;
75 readonly hasPendingShellSnapshot: boolean;
76 readonly hasReadyEnvironment: boolean;
77 readonly hasConnectingEnvironment: boolean;
78 readonly connectionState: EnvironmentConnectionState;
79 readonly connectionError: string | null;
80 readonly shellSnapshotError: string | null;
81 readonly isUsingCachedData: boolean;
82 readonly latestCachedSnapshotReceivedAt: string | null;
83}
84
85export function useRemoteCatalog() {
86 const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();
87 const { environmentStateById, isLoadingSavedConnection, savedConnectionsById } =
88 useRemoteEnvironmentState();
89 const catalogEnvironmentIds = useMemo(
90 () => listRemoteCatalogEnvironmentIds(savedConnectionsById),
91 [savedConnectionsById],
92 );
93 const shellSnapshotStates = useShellSnapshotStates(catalogEnvironmentIds);
94 const cachedShellSnapshotMetadata = useCachedShellSnapshotMetadata();
95
96 const projects = useMemo(() => {
97 const scopedProjects: EnvironmentScopedProjectShell[] = [];
98 for (const connection of Object.values(savedConnectionsById)) {
99 const projects = shellSnapshotStates[connection.environmentId]?.data?.projects ?? [];
100 for (const project of projects) {
101 scopedProjects.push(scopeProjectShell(connection.environmentId, project));
102 }
103 }
104 return Arr.sort(scopedProjects, projectsSortOrder);
105 }, [savedConnectionsById, shellSnapshotStates]);
106
107 const threads = useMemo(() => {
108 const scopedThreads: EnvironmentScopedThreadShell[] = [];
109 for (const connection of Object.values(savedConnectionsById)) {
110 const threads = shellSnapshotStates[connection.environmentId]?.data?.threads ?? [];
111 for (const thread of threads) {
112 scopedThreads.push(scopeThreadShell(connection.environmentId, thread));
113 }
114 }
115 return Arr.sort(scopedThreads, threadsSortOrder);
116 }, [savedConnectionsById, shellSnapshotStates]);
117
118 const serverConfigByEnvironmentId = useMemo(
119 () =>
120 Object.fromEntries(
121 Object.entries(environmentStateById).map(([environmentId, runtime]) => [
122 environmentId,
123 runtime.serverConfig ?? null,
124 ]),
125 ),
126 [environmentStateById],
127 );
128
129 const overallConnectionState = useMemo(
130 () => deriveOverallConnectionState(connectedEnvironments),
131 [connectedEnvironments],
132 );
133
134 const hasRemoteActivity = useMemo(
135 () =>
136 threads.some(
137 (thread) => thread.session?.status === "running" || thread.session?.status === "starting",
138 ),
139 [threads],
140 );
141
142 const state = useMemo<RemoteCatalogState>(() => {
143 const shellSnapshots = Object.values(shellSnapshotStates);
144 const cachedSnapshotReceivedAts: string[] = [];
145 for (const environmentId of catalogEnvironmentIds) {
146 const metadata = cachedShellSnapshotMetadata[environmentId];
147 if (metadata) {
148 cachedSnapshotReceivedAts.push(metadata.snapshotReceivedAt);
149 }
150 }
151 let shellSnapshotError: string | null = null;
152 for (const snapshot of shellSnapshots) {
153 if (snapshot.error !== null) {
154 shellSnapshotError = snapshot.error;
155 break;
156 }
157 }
158 return {
159 isLoadingSavedConnections: isLoadingSavedConnection,
160 hasSavedConnections: catalogEnvironmentIds.length > 0,
161 hasLoadedShellSnapshot: shellSnapshots.some((snapshot) => snapshot.data !== null),
162 hasPendingShellSnapshot: shellSnapshots.some((snapshot) => snapshot.isPending),
163 hasReadyEnvironment: connectedEnvironments.some(
164 (environment) => environment.connectionState === "ready",
165 ),
166 hasConnectingEnvironment: connectedEnvironments.some(
167 (environment) =>
168 environment.connectionState === "connecting" ||
169 environment.connectionState === "reconnecting",
170 ),
171 connectionState: connectionState ?? overallConnectionState,
172 connectionError,
173 shellSnapshotError,
174 isUsingCachedData: cachedSnapshotReceivedAts.length > 0,
175 latestCachedSnapshotReceivedAt:
176 Arr.sort(cachedSnapshotReceivedAts, Order.flip(Order.String))[0] ?? null,
177 };
178 }, [
179 cachedShellSnapshotMetadata,
180 catalogEnvironmentIds,
181 connectedEnvironments,
182 connectionError,
183 connectionState,
184 isLoadingSavedConnection,
185 overallConnectionState,
186 shellSnapshotStates,
187 ]);
188
189 return {
190 projects,
191 threads,
192 serverConfigByEnvironmentId,
193 connectionState: state.connectionState,
194 connectionError: state.connectionError,
195 state,
196 hasRemoteActivity,
197 };
198}
apps/mobile/src/state/use-remote-environment-registry.ts created+618
...@@ -0,0 +1,618 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo } from "react";
3import { Alert } from "react-native";
4
5import {
6 type EnvironmentRuntimeState,
7 createEnvironmentConnection,
8 createEnvironmentConnectionAttemptRegistry,
9 createKnownEnvironment,
10 createWsRpcClient,
11 EnvironmentConnectionState,
12 WsTransport,
13 resolveRemoteWebSocketConnectionUrl,
14} from "@t3tools/client-runtime";
15import type { EnvironmentId } from "@t3tools/contracts";
16import * as Arr from "effect/Array";
17import * as Order from "effect/Order";
18import * as Option from "effect/Option";
19import { pipe } from "effect/Function";
20import { Atom } from "effect/unstable/reactivity";
21import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection";
22import { terminalDebugLog } from "../features/terminal/terminalDebugLog";
23import {
24 clearCachedShellSnapshot,
25 clearSavedConnection,
26 loadCachedShellSnapshot,
27 loadSavedConnections,
28 saveCachedShellSnapshot,
29 saveConnection,
30} from "../lib/storage";
31import { appAtomRegistry } from "./atom-registry";
32import { mobileRemoteHttpRuntime } from "../lib/runtime";
33import {
34 drainEnvironmentSessions,
35 notifyEnvironmentConnectionListeners,
36 removeEnvironmentSession,
37 setEnvironmentSession,
38} from "./environment-session-registry";
39import { type ConnectedEnvironmentSummary } from "./remote-runtime-types";
40import {
41 invalidateSourceControlDiscoveryForEnvironment,
42 resetSourceControlDiscoveryState,
43} from "./use-source-control-discovery";
44import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
45import {
46 clearCachedShellSnapshotMetadata,
47 hydrateCachedShellSnapshot,
48 markShellSnapshotLive,
49 shellSnapshotManager,
50} from "./use-shell-snapshot";
51import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session";
52
53const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>();
54const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry();
55const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000;
56
57interface RemoteEnvironmentLocalState {
58 readonly isLoadingSavedConnection: boolean;
59 readonly connectionPairingUrl: string;
60 readonly pendingConnectionError: string | null;
61 readonly savedConnectionsById: Record<EnvironmentId, SavedRemoteConnection>;
62}
63
64const isLoadingSavedConnectionAtom = Atom.make(true).pipe(
65 Atom.keepAlive,
66 Atom.withLabel("mobile:is-loading-saved-connection"),
67);
68
69const connectionPairingUrlAtom = Atom.make("").pipe(
70 Atom.keepAlive,
71 Atom.withLabel("mobile:connection-pairing-url"),
72);
73
74const pendingConnectionErrorAtom = Atom.make<string | null>(null).pipe(
75 Atom.keepAlive,
76 Atom.withLabel("mobile:pending-connection-error"),
77);
78
79const savedConnectionsByIdAtom = Atom.make<Record<EnvironmentId, SavedRemoteConnection>>({}).pipe(
80 Atom.keepAlive,
81 Atom.withLabel("mobile:saved-connections"),
82);
83
84function getSavedConnectionsById(): Record<EnvironmentId, SavedRemoteConnection> {
85 return appAtomRegistry.get(savedConnectionsByIdAtom);
86}
87
88function setIsLoadingSavedConnection(value: boolean): void {
89 appAtomRegistry.set(isLoadingSavedConnectionAtom, value);
90}
91
92function setConnectionPairingUrl(pairingUrl: string): void {
93 appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl);
94}
95
96function clearConnectionPairingUrl(): void {
97 appAtomRegistry.set(connectionPairingUrlAtom, "");
98}
99
100export function setPendingConnectionError(message: string | null): void {
101 appAtomRegistry.set(pendingConnectionErrorAtom, message);
102}
103
104function clearPendingConnectionError(): void {
105 appAtomRegistry.set(pendingConnectionErrorAtom, null);
106}
107
108function replaceSavedConnections(connections: Record<EnvironmentId, SavedRemoteConnection>): void {
109 appAtomRegistry.set(savedConnectionsByIdAtom, connections);
110}
111
112function upsertSavedConnection(connection: SavedRemoteConnection): void {
113 const current = appAtomRegistry.get(savedConnectionsByIdAtom);
114 appAtomRegistry.set(savedConnectionsByIdAtom, {
115 ...current,
116 [connection.environmentId]: connection,
117 });
118}
119
120function removeSavedConnection(environmentId: EnvironmentId): void {
121 const current = appAtomRegistry.get(savedConnectionsByIdAtom);
122 const next = { ...current };
123 delete next[environmentId];
124 appAtomRegistry.set(savedConnectionsByIdAtom, next);
125}
126
127function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState {
128 const isLoadingSavedConnection = useAtomValue(isLoadingSavedConnectionAtom);
129 const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom);
130 const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
131 const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom);
132
133 return useMemo(
134 () => ({
135 isLoadingSavedConnection,
136 connectionPairingUrl,
137 pendingConnectionError,
138 savedConnectionsById,
139 }),
140 [connectionPairingUrl, isLoadingSavedConnection, pendingConnectionError, savedConnectionsById],
141 );
142}
143
144function setEnvironmentConnectionStatus(
145 environmentId: EnvironmentId,
146 state: ConnectedEnvironmentSummary["connectionState"],
147 error?: string | null,
148) {
149 environmentRuntimeManager.patch({ environmentId }, (current) => ({
150 ...current,
151 connectionState: state,
152 connectionError: error === undefined ? current.connectionError : error,
153 }));
154}
155
156function withTimeout<T>(
157 promise: Promise<T>,
158 timeoutMs: number,
159 timeoutMessage: string,
160): Promise<T> {
161 return new Promise<T>((resolve, reject) => {
162 const timeoutId = setTimeout(() => {
163 reject(new Error(timeoutMessage));
164 }, timeoutMs);
165
166 promise.then(
167 (value) => {
168 clearTimeout(timeoutId);
169 resolve(value);
170 },
171 (error: unknown) => {
172 clearTimeout(timeoutId);
173 reject(error);
174 },
175 );
176 });
177}
178
179export async function disconnectEnvironment(
180 environmentId: EnvironmentId,
181 options?: {
182 readonly preserveShellSnapshot?: boolean;
183 readonly removeSaved?: boolean;
184 readonly preserveConnectionAttempt?: boolean;
185 },
186) {
187 if (!options?.preserveConnectionAttempt) {
188 environmentConnectionAttempts.cancel(environmentId);
189 }
190
191 const session = removeEnvironmentSession(environmentId);
192 notifyEnvironmentConnectionListeners();
193 await session?.connection.dispose();
194 terminalMetadataUnsubscribers.get(environmentId)?.();
195 terminalMetadataUnsubscribers.delete(environmentId);
196 if (!options?.preserveShellSnapshot) {
197 shellSnapshotManager.invalidate({ environmentId });
198 }
199 invalidateSourceControlDiscoveryForEnvironment(environmentId);
200 terminalSessionManager.invalidateEnvironment(environmentId);
201 environmentRuntimeManager.invalidate({ environmentId });
202
203 if (options?.removeSaved) {
204 await clearSavedConnection(environmentId);
205 await clearCachedShellSnapshot(environmentId);
206 clearCachedShellSnapshotMetadata(environmentId);
207 removeSavedConnection(environmentId);
208 }
209}
210
211export async function connectSavedEnvironment(
212 connection: SavedRemoteConnection,
213 options?: { readonly persist?: boolean },
214) {
215 const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId);
216 const isCurrentAttempt = connectionAttempt.isCurrent;
217
218 await disconnectEnvironment(connection.environmentId, {
219 preserveShellSnapshot: true,
220 preserveConnectionAttempt: true,
221 });
222 if (!isCurrentAttempt()) {
223 return;
224 }
225
226 if (options?.persist !== false) {
227 await saveConnection(connection);
228 if (!isCurrentAttempt()) {
229 return;
230 }
231 }
232
233 upsertSavedConnection(connection);
234 setEnvironmentConnectionStatus(connection.environmentId, "connecting", null);
235 shellSnapshotManager.markPending({ environmentId: connection.environmentId });
236
237 const transport = new WsTransport(
238 () =>
239 mobileRemoteHttpRuntime.runPromise(
240 resolveRemoteWebSocketConnectionUrl({
241 wsBaseUrl: connection.wsBaseUrl,
242 httpBaseUrl: connection.httpBaseUrl,
243 bearerToken: connection.bearerToken,
244 }),
245 ),
246 {
247 onAttempt: () => {
248 if (!isCurrentAttempt()) {
249 return;
250 }
251
252 environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => {
253 const nextState =
254 previous.connectionState === "ready" || previous.connectionState === "reconnecting"
255 ? "reconnecting"
256 : "connecting";
257 const keepSettledFailure =
258 previous.connectionState === "disconnected" && previous.connectionError !== null;
259 return {
260 ...previous,
261 connectionState: keepSettledFailure ? "disconnected" : nextState,
262 connectionError: keepSettledFailure ? previous.connectionError : null,
263 };
264 });
265 },
266 onError: (message) => {
267 if (isCurrentAttempt()) {
268 setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message);
269 }
270 },
271 onClose: (details) => {
272 if (!isCurrentAttempt()) {
273 return;
274 }
275
276 const reason =
277 details.reason.trim().length > 0
278 ? details.reason
279 : details.code === 1000
280 ? null
281 : `Remote connection closed (${details.code}).`;
282 setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason);
283 },
284 },
285 );
286
287 const client = createWsRpcClient(transport);
288 const environmentConnection = createEnvironmentConnection({
289 kind: "saved",
290 knownEnvironment: {
291 ...createKnownEnvironment({
292 id: connection.environmentId,
293 label: connection.environmentLabel,
294 source: "manual",
295 target: {
296 httpBaseUrl: connection.httpBaseUrl,
297 wsBaseUrl: connection.wsBaseUrl,
298 },
299 }),
300 environmentId: connection.environmentId,
301 },
302 client,
303 applyShellEvent: (event, environmentId) => {
304 if (isCurrentAttempt()) {
305 shellSnapshotManager.applyEvent({ environmentId }, event);
306 }
307 },
308 syncShellSnapshot: (snapshot, environmentId) => {
309 if (!isCurrentAttempt()) {
310 return;
311 }
312
313 shellSnapshotManager.syncSnapshot({ environmentId }, snapshot);
314 markShellSnapshotLive(environmentId);
315 void saveCachedShellSnapshot(environmentId, snapshot);
316 environmentRuntimeManager.patch({ environmentId }, (runtime) => ({
317 ...runtime,
318 connectionState: "ready",
319 connectionError: null,
320 }));
321 },
322 onShellResubscribe: (environmentId) => {
323 if (isCurrentAttempt()) {
324 shellSnapshotManager.markPending({ environmentId });
325 }
326 },
327 onConfigSnapshot: (serverConfig) => {
328 if (isCurrentAttempt()) {
329 environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (runtime) => ({
330 ...runtime,
331 serverConfig,
332 }));
333 }
334 },
335 });
336
337 if (!isCurrentAttempt()) {
338 await environmentConnection.dispose();
339 return;
340 }
341
342 setEnvironmentSession(connection.environmentId, {
343 client,
344 connection: environmentConnection,
345 });
346 terminalMetadataUnsubscribers.set(
347 connection.environmentId,
348 subscribeTerminalMetadata({
349 environmentId: connection.environmentId,
350 client,
351 }),
352 );
353 terminalDebugLog("registry:terminal-metadata-subscribed", {
354 environmentId: connection.environmentId,
355 });
356 notifyEnvironmentConnectionListeners();
357
358 try {
359 await withTimeout(
360 environmentConnection.ensureBootstrapped(),
361 SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS,
362 "Environment did not respond before the connection timeout.",
363 );
364 } catch (error) {
365 if (isCurrentAttempt()) {
366 setEnvironmentConnectionStatus(
367 connection.environmentId,
368 "disconnected",
369 error instanceof Error ? error.message : "Failed to bootstrap remote connection.",
370 );
371 }
372 }
373}
374
375const environmentsSortOrder = Order.mapInput(
376 Order.Struct({
377 environmentLabel: Order.String,
378 }),
379 (environment: ConnectedEnvironmentSummary) => ({
380 environmentLabel: environment.environmentLabel,
381 }),
382);
383
384function deriveConnectedEnvironments(
385 savedConnectionsById: Record<string, SavedRemoteConnection>,
386 environmentStateById: Record<EnvironmentId, EnvironmentRuntimeState>,
387): ReadonlyArray<ConnectedEnvironmentSummary> {
388 return Arr.sort(
389 Object.values(savedConnectionsById).map((connection) => {
390 const runtime = environmentStateById[connection.environmentId];
391 return {
392 environmentId: connection.environmentId,
393 environmentLabel: connection.environmentLabel,
394 displayUrl: connection.displayUrl,
395 connectionState: runtime?.connectionState ?? "idle",
396 connectionError: runtime?.connectionError ?? null,
397 };
398 }),
399 environmentsSortOrder,
400 );
401}
402
403export function useRemoteEnvironmentBootstrap() {
404 useEffect(() => {
405 let cancelled = false;
406
407 void loadSavedConnections()
408 .then((connections) => {
409 if (cancelled) {
410 return;
411 }
412
413 replaceSavedConnections(
414 Object.fromEntries(
415 connections.map((connection) => [connection.environmentId, connection]),
416 ),
417 );
418
419 setIsLoadingSavedConnection(false);
420
421 void (async () => {
422 await Promise.all(
423 connections.map(async (connection) => {
424 const cached = await loadCachedShellSnapshot(connection.environmentId);
425 if (!cancelled && cached) {
426 hydrateCachedShellSnapshot(cached);
427 }
428 }),
429 );
430
431 if (cancelled) {
432 return;
433 }
434
435 await Promise.all(
436 connections.map((connection) =>
437 connectSavedEnvironment(connection, {
438 persist: false,
439 }),
440 ),
441 );
442 })();
443 })
444 .catch(() => {
445 if (!cancelled) {
446 setIsLoadingSavedConnection(false);
447 }
448 });
449
450 return () => {
451 cancelled = true;
452 for (const session of drainEnvironmentSessions()) {
453 void session.connection.dispose();
454 }
455 for (const unsubscribe of terminalMetadataUnsubscribers.values()) {
456 unsubscribe();
457 }
458 terminalMetadataUnsubscribers.clear();
459 environmentConnectionAttempts.clear();
460 environmentRuntimeManager.invalidate();
461 shellSnapshotManager.invalidate();
462 resetSourceControlDiscoveryState();
463 terminalSessionManager.invalidate();
464 notifyEnvironmentConnectionListeners();
465 };
466 }, []);
467}
468
469export function useRemoteEnvironmentState() {
470 const state = useRemoteEnvironmentLocalState();
471 const environmentStateById = useEnvironmentRuntimeStates(
472 Object.values(state.savedConnectionsById).map((connection) => connection.environmentId),
473 );
474
475 return useMemo(
476 () => ({
477 ...state,
478 environmentStateById,
479 }),
480 [environmentStateById, state],
481 );
482}
483
484export function useRemoteConnectionStatus() {
485 const { environmentStateById, pendingConnectionError, savedConnectionsById } =
486 useRemoteEnvironmentState();
487
488 const connectedEnvironments = useMemo(
489 () => deriveConnectedEnvironments(savedConnectionsById, environmentStateById),
490 [environmentStateById, savedConnectionsById],
491 );
492
493 const connectionState = useMemo<EnvironmentConnectionState>(() => {
494 if (connectedEnvironments.length === 0) {
495 return "idle";
496 }
497 if (connectedEnvironments.some((environment) => environment.connectionState === "ready")) {
498 return "ready";
499 }
500 if (
501 connectedEnvironments.some((environment) => environment.connectionState === "reconnecting")
502 ) {
503 return "reconnecting";
504 }
505 if (connectedEnvironments.some((environment) => environment.connectionState === "connecting")) {
506 return "connecting";
507 }
508 return "disconnected";
509 }, [connectedEnvironments]);
510
511 const connectionError = useMemo(
512 () =>
513 pipe(
514 Arr.appendAll(
515 [pendingConnectionError],
516 Arr.map(connectedEnvironments, (environment) => environment.connectionError),
517 ),
518 Arr.findFirst((value) => value !== null),
519 Option.getOrNull,
520 ),
521 [connectedEnvironments, pendingConnectionError],
522 );
523
524 return {
525 connectedEnvironments,
526 connectionState,
527 connectionError,
528 };
529}
530
531export function useRemoteConnections() {
532 const { connectionPairingUrl, pendingConnectionError } = useRemoteEnvironmentState();
533 const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();
534
535 const onConnectPress = useCallback(
536 async (pairingUrl?: string) => {
537 try {
538 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;
539 const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl });
540 clearPendingConnectionError();
541 await connectSavedEnvironment(connection);
542 clearConnectionPairingUrl();
543 } catch (error) {
544 setPendingConnectionError(
545 error instanceof Error ? error.message : "Failed to pair with the environment.",
546 );
547 throw error;
548 }
549 },
550 [connectionPairingUrl],
551 );
552
553 const onUpdateEnvironment = useCallback(
554 async (
555 environmentId: EnvironmentId,
556 updates: { readonly label: string; readonly displayUrl: string },
557 ) => {
558 const connection = getSavedConnectionsById()[environmentId];
559 if (!connection) {
560 return;
561 }
562
563 const updated: SavedRemoteConnection = {
564 ...connection,
565 environmentLabel: updates.label.trim() || connection.environmentLabel,
566 displayUrl: updates.displayUrl.trim() || connection.displayUrl,
567 };
568
569 await saveConnection(updated);
570 upsertSavedConnection(updated);
571 },
572 [],
573 );
574
575 const onReconnectEnvironment = useCallback((environmentId: EnvironmentId) => {
576 const connection = getSavedConnectionsById()[environmentId];
577 if (!connection) {
578 return;
579 }
580 void connectSavedEnvironment(connection, { persist: false });
581 }, []);
582
583 const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => {
584 const connection = getSavedConnectionsById()[environmentId];
585 if (!connection) {
586 return;
587 }
588
589 Alert.alert(
590 "Remove environment?",
591 `Disconnect and forget ${connection.environmentLabel} on this device.`,
592 [
593 { text: "Cancel", style: "cancel" },
594 {
595 text: "Remove",
596 style: "destructive",
597 onPress: () => {
598 void disconnectEnvironment(environmentId, { removeSaved: true });
599 },
600 },
601 ],
602 );
603 }, []);
604
605 return {
606 connectionPairingUrl,
607 connectionState,
608 connectionError,
609 pairingConnectionError: pendingConnectionError,
610 connectedEnvironments,
611 connectedEnvironmentCount: connectedEnvironments.length,
612 onChangeConnectionPairingUrl: setConnectionPairingUrl,
613 onConnectPress,
614 onReconnectEnvironment,
615 onUpdateEnvironment,
616 onRemoveEnvironmentPress,
617 };
618}
apps/mobile/src/state/use-selected-thread-commands.ts created+187
...@@ -0,0 +1,187 @@
1import { useCallback } from "react";
2
3import {
4 CommandId,
5 type ModelSelection,
6 type ProviderInteractionMode,
7 type RuntimeMode,
8} from "@t3tools/contracts";
9
10import { uuidv4 } from "../lib/uuid";
11import { environmentRuntimeManager } from "./use-environment-runtime";
12import { getEnvironmentClient } from "./environment-session-registry";
13import { useRemoteEnvironmentState } from "./use-remote-environment-registry";
14import { useThreadSelection } from "./use-thread-selection";
15
16export function useSelectedThreadCommands(input: {
17 readonly refreshSelectedThreadGitStatus: (options?: {
18 readonly quiet?: boolean;
19 readonly cwd?: string | null;
20 }) => Promise<unknown>;
21}) {
22 const { refreshSelectedThreadGitStatus } = input;
23 const { selectedThread } = useThreadSelection();
24 const { savedConnectionsById } = useRemoteEnvironmentState();
25
26 const onRefresh = useCallback(async () => {
27 const targets = selectedThread
28 ? [selectedThread.environmentId]
29 : Object.values(savedConnectionsById).map((connection) => connection.environmentId);
30
31 await Promise.all(
32 targets.map(async (environmentId) => {
33 const client = getEnvironmentClient(environmentId);
34 if (!client) {
35 return;
36 }
37
38 try {
39 const serverConfig = await client.server.getConfig();
40 environmentRuntimeManager.patch({ environmentId }, (current) => ({
41 ...current,
42 serverConfig,
43 connectionError: null,
44 }));
45 } catch (error) {
46 environmentRuntimeManager.patch({ environmentId }, (current) => ({
47 ...current,
48 connectionError:
49 error instanceof Error ? error.message : "Failed to refresh remote data.",
50 }));
51 }
52 }),
53 );
54
55 if (selectedThread) {
56 await refreshSelectedThreadGitStatus({ quiet: true });
57 }
58 }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]);
59
60 const onUpdateThreadModelSelection = useCallback(
61 async (modelSelection: ModelSelection) => {
62 if (!selectedThread) {
63 return;
64 }
65
66 const client = getEnvironmentClient(selectedThread.environmentId);
67 if (!client) {
68 return;
69 }
70
71 await client.orchestration.dispatchCommand({
72 type: "thread.meta.update",
73 commandId: CommandId.make(uuidv4()),
74 threadId: selectedThread.id,
75 modelSelection,
76 });
77 },
78 [selectedThread],
79 );
80
81 const onUpdateThreadRuntimeMode = useCallback(
82 async (runtimeMode: RuntimeMode) => {
83 if (!selectedThread) {
84 return;
85 }
86
87 const client = getEnvironmentClient(selectedThread.environmentId);
88 if (!client) {
89 return;
90 }
91
92 await client.orchestration.dispatchCommand({
93 type: "thread.runtime-mode.set",
94 commandId: CommandId.make(uuidv4()),
95 threadId: selectedThread.id,
96 runtimeMode,
97 createdAt: new Date().toISOString(),
98 });
99 },
100 [selectedThread],
101 );
102
103 const onUpdateThreadInteractionMode = useCallback(
104 async (interactionMode: ProviderInteractionMode) => {
105 if (!selectedThread) {
106 return;
107 }
108
109 const client = getEnvironmentClient(selectedThread.environmentId);
110 if (!client) {
111 return;
112 }
113
114 await client.orchestration.dispatchCommand({
115 type: "thread.interaction-mode.set",
116 commandId: CommandId.make(uuidv4()),
117 threadId: selectedThread.id,
118 interactionMode,
119 createdAt: new Date().toISOString(),
120 });
121 },
122 [selectedThread],
123 );
124
125 const onStopThread = useCallback(async () => {
126 if (!selectedThread) {
127 return;
128 }
129
130 const client = getEnvironmentClient(selectedThread.environmentId);
131 if (!client) {
132 return;
133 }
134
135 if (
136 selectedThread.session?.status !== "running" &&
137 selectedThread.session?.status !== "starting"
138 ) {
139 return;
140 }
141
142 await client.orchestration.dispatchCommand({
143 type: "thread.turn.interrupt",
144 commandId: CommandId.make(uuidv4()),
145 threadId: selectedThread.id,
146 ...(selectedThread.session?.activeTurnId
147 ? { turnId: selectedThread.session.activeTurnId }
148 : {}),
149 createdAt: new Date().toISOString(),
150 });
151 }, [selectedThread]);
152
153 const onRenameThread = useCallback(
154 async (title: string) => {
155 if (!selectedThread) {
156 return;
157 }
158
159 const client = getEnvironmentClient(selectedThread.environmentId);
160 if (!client) {
161 return;
162 }
163
164 const trimmed = title.trim();
165 if (!trimmed || trimmed === selectedThread.title) {
166 return;
167 }
168
169 await client.orchestration.dispatchCommand({
170 type: "thread.meta.update",
171 commandId: CommandId.make(uuidv4()),
172 threadId: selectedThread.id,
173 title: trimmed,
174 });
175 },
176 [selectedThread],
177 );
178
179 return {
180 onRefresh,
181 onUpdateThreadModelSelection,
182 onUpdateThreadRuntimeMode,
183 onUpdateThreadInteractionMode,
184 onRenameThread,
185 onStopThread,
186 };
187}
apps/mobile/src/state/use-selected-thread-git-actions.ts created+341
...@@ -0,0 +1,341 @@
1import { useCallback, useEffect } from "react";
2
3import {
4 EnvironmentScopedProjectShell,
5 EnvironmentScopedThreadShell,
6 type VcsRef,
7 type GitActionRequestInput,
8} from "@t3tools/client-runtime";
9import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts";
10import {
11 dedupeRemoteBranchesWithLocalMatches,
12 sanitizeFeatureBranchName,
13} from "@t3tools/shared/git";
14
15import { uuidv4 } from "../lib/uuid";
16import { getEnvironmentClient } from "./environment-session-registry";
17import { setPendingConnectionError } from "./use-remote-environment-registry";
18import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state";
19import { vcsRefManager } from "./use-vcs-refs";
20import { vcsStatusManager } from "./use-vcs-status";
21import { useThreadSelection } from "./use-thread-selection";
22import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";
23
24export function useSelectedThreadGitActions() {
25 const { selectedThread, selectedThreadProject } = useThreadSelection();
26 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
27
28 const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null;
29
30 const updateThreadGitContext = useCallback(
31 async (
32 thread: NonNullable<typeof selectedThread>,
33 nextState: {
34 readonly branch?: string | null;
35 readonly worktreePath?: string | null;
36 },
37 ) => {
38 const client = getEnvironmentClient(thread.environmentId);
39 if (!client) {
40 return;
41 }
42
43 await client.orchestration.dispatchCommand({
44 type: "thread.meta.update",
45 commandId: CommandId.make(uuidv4()),
46 threadId: thread.id,
47 ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}),
48 ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}),
49 });
50 },
51 [],
52 );
53
54 const refreshSelectedThreadGitStatus = useCallback(
55 async (options?: { readonly quiet?: boolean; readonly cwd?: string | null }) => {
56 if (!selectedThread || !selectedThreadProject) {
57 return null;
58 }
59
60 const cwd = options?.cwd ?? selectedThreadCwd;
61 if (!cwd) {
62 return null;
63 }
64
65 try {
66 const client = getEnvironmentClient(selectedThread.environmentId);
67 if (!client) {
68 return null;
69 }
70
71 const status = await vcsActionManager.refreshStatus(
72 { environmentId: selectedThread.environmentId, cwd },
73 { ...client.vcs, runChangeRequest: client.git.runStackedAction },
74 options,
75 );
76 setPendingConnectionError(null);
77 return status;
78 } catch (error) {
79 const message = error instanceof Error ? error.message : "Failed to refresh git status.";
80 setPendingConnectionError(message);
81 return null;
82 }
83 },
84 [selectedThread, selectedThreadCwd, selectedThreadProject],
85 );
86
87 useEffect(() => {
88 if (!selectedThread || !selectedThreadProject) {
89 return;
90 }
91
92 void refreshSelectedThreadGitStatus({ quiet: true });
93 }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]);
94
95 const runSelectedThreadGitMutation = useCallback(
96 async <T>(
97 operation: (input: {
98 readonly thread: EnvironmentScopedThreadShell;
99 readonly project: EnvironmentScopedProjectShell;
100 readonly cwd: string;
101 }) => Promise<T>,
102 ): Promise<T | null> => {
103 if (!selectedThread || !selectedThreadProject) {
104 return null;
105 }
106
107 const cwd = selectedThreadCwd;
108 if (!cwd) {
109 return null;
110 }
111
112 try {
113 setPendingConnectionError(null);
114 return await operation({
115 thread: selectedThread,
116 project: selectedThreadProject,
117 cwd,
118 });
119 } catch (error) {
120 const message = error instanceof Error ? error.message : "Git action failed.";
121 setPendingConnectionError(message);
122 showGitActionResult({ type: "error", title: "Git action failed", description: message });
123 return null;
124 }
125 },
126 [selectedThread, selectedThreadCwd, selectedThreadProject],
127 );
128
129 const refreshSelectedThreadBranches = useCallback(async (): Promise<ReadonlyArray<VcsRef>> => {
130 if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) {
131 return [];
132 }
133
134 const client = getEnvironmentClient(selectedThread.environmentId);
135 if (!client) {
136 return [];
137 }
138
139 try {
140 const result = await vcsRefManager.load(
141 { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null },
142 client.vcs,
143 { limit: 100 },
144 );
145 return dedupeRemoteBranchesWithLocalMatches(result?.refs ?? []).filter(
146 (branch) => !branch.isRemote,
147 );
148 } catch (error) {
149 setPendingConnectionError(
150 error instanceof Error ? error.message : "Failed to load branches.",
151 );
152 return [];
153 }
154 }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]);
155
156 const syncSelectedThreadBranchState = useCallback(
157 async (input: {
158 readonly thread: EnvironmentScopedThreadShell;
159 readonly cwd: string;
160 readonly branchRootCwd?: string | null;
161 readonly nextThreadState?: {
162 readonly branch?: string | null;
163 readonly worktreePath?: string | null;
164 };
165 }) => {
166 if (input.nextThreadState) {
167 await updateThreadGitContext(input.thread, input.nextThreadState);
168 }
169
170 const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null;
171 if (branchRootCwd) {
172 vcsRefManager.invalidate({
173 environmentId: input.thread.environmentId,
174 cwd: branchRootCwd,
175 query: null,
176 });
177 await refreshSelectedThreadBranches();
178 }
179
180 await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd });
181 },
182 [
183 refreshSelectedThreadBranches,
184 refreshSelectedThreadGitStatus,
185 selectedThreadProject?.workspaceRoot,
186 updateThreadGitContext,
187 ],
188 );
189
190 const onCheckoutSelectedThreadBranch = useCallback(
191 async (branch: string) => {
192 await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
193 const result = await vcsActionManager.switchRef(
194 { environmentId: thread.environmentId, cwd },
195 { refName: branch },
196 );
197 await syncSelectedThreadBranchState({
198 thread,
199 cwd,
200 nextThreadState: {
201 branch: result?.refName ?? thread.branch,
202 worktreePath: selectedThreadWorktreePath,
203 },
204 });
205 });
206 },
207 [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState],
208 );
209
210 const onCreateSelectedThreadBranch = useCallback(
211 async (branch: string) => {
212 await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
213 const result = await vcsActionManager.createRef(
214 { environmentId: thread.environmentId, cwd },
215 {
216 refName: branch,
217 switchRef: true,
218 },
219 );
220 await syncSelectedThreadBranchState({
221 thread,
222 cwd,
223 nextThreadState: {
224 branch: result?.refName ?? thread.branch,
225 worktreePath: selectedThreadWorktreePath,
226 },
227 });
228 });
229 },
230 [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState],
231 );
232
233 const onCreateSelectedThreadWorktree = useCallback(
234 async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => {
235 await runSelectedThreadGitMutation(async ({ thread, project }) => {
236 const result = await vcsActionManager.createWorktree(
237 { environmentId: thread.environmentId, cwd: project.workspaceRoot },
238 {
239 refName: nextWorktree.baseBranch,
240 newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch),
241 path: null,
242 },
243 );
244 if (!result) {
245 return;
246 }
247
248 await syncSelectedThreadBranchState({
249 thread,
250 cwd: result.worktree.path,
251 branchRootCwd: project.workspaceRoot,
252 nextThreadState: {
253 branch: result.worktree.refName,
254 worktreePath: result.worktree.path,
255 },
256 });
257 });
258 },
259 [runSelectedThreadGitMutation, syncSelectedThreadBranchState],
260 );
261
262 const onPullSelectedThreadBranch = useCallback(async () => {
263 await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
264 const result = await vcsActionManager.pull({ environmentId: thread.environmentId, cwd });
265 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
266 if (result) {
267 showGitActionResult({
268 type: "success",
269 title:
270 result.status === "skipped_up_to_date"
271 ? "Already up to date"
272 : `Pulled latest on ${result.refName}`,
273 });
274 }
275 });
276 }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);
277
278 const onRunSelectedThreadGitAction = useCallback(
279 async (input: GitActionRequestInput): Promise<GitRunStackedActionResult | null> => {
280 return await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
281 const result = await vcsActionManager.runChangeRequest(
282 { environmentId: thread.environmentId, cwd },
283 {
284 actionId: uuidv4(),
285 action: input.action,
286 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
287 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
288 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
289 },
290 {
291 gitStatus: vcsStatusManager.getSnapshot({
292 environmentId: thread.environmentId,
293 cwd,
294 }).data,
295 },
296 );
297 if (!result) {
298 return null;
299 }
300
301 showGitActionResult({
302 type: "success",
303 title: result.toast.title,
304 description: result.toast.description,
305 prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined,
306 });
307
308 if (result.branch.status === "created" && result.branch.name) {
309 await syncSelectedThreadBranchState({
310 thread,
311 cwd,
312 nextThreadState: {
313 branch: result.branch.name,
314 worktreePath: selectedThreadWorktreePath,
315 },
316 });
317 return result;
318 }
319
320 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
321 return result;
322 });
323 },
324 [
325 refreshSelectedThreadGitStatus,
326 runSelectedThreadGitMutation,
327 selectedThreadWorktreePath,
328 syncSelectedThreadBranchState,
329 ],
330 );
331
332 return {
333 refreshSelectedThreadGitStatus,
334 refreshSelectedThreadBranches,
335 onCheckoutSelectedThreadBranch,
336 onCreateSelectedThreadBranch,
337 onCreateSelectedThreadWorktree,
338 onPullSelectedThreadBranch,
339 onRunSelectedThreadGitAction,
340 };
341}
apps/mobile/src/state/use-selected-thread-git-state.ts created+48
...@@ -0,0 +1,48 @@
1import { useMemo } from "react";
2
3import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
4
5import { useVcsActionState } from "./use-vcs-action-state";
6import { useVcsRefs } from "./use-vcs-refs";
7import { useSourceControlDiscovery } from "./use-source-control-discovery";
8import { useThreadSelection } from "./use-thread-selection";
9import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";
10
11export function useSelectedThreadGitState() {
12 const { selectedThread, selectedThreadProject } = useThreadSelection();
13 const { selectedThreadCwd } = useSelectedThreadWorktree();
14
15 const selectedThreadGitTarget = useMemo(
16 () => ({
17 environmentId: selectedThread?.environmentId ?? null,
18 cwd: selectedThreadCwd,
19 }),
20 [selectedThread?.environmentId, selectedThreadCwd],
21 );
22 const gitActionState = useVcsActionState(selectedThreadGitTarget);
23 const sourceControlDiscovery = useSourceControlDiscovery(selectedThread?.environmentId ?? null);
24
25 const selectedThreadBranchTarget = useMemo(
26 () => ({
27 environmentId: selectedThread?.environmentId ?? null,
28 cwd: selectedThreadProject?.workspaceRoot ?? null,
29 query: null,
30 }),
31 [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot],
32 );
33 const selectedThreadBranchState = useVcsRefs(selectedThreadBranchTarget);
34 const selectedThreadBranches = useMemo(
35 () =>
36 dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter(
37 (branch) => !branch.isRemote,
38 ),
39 [selectedThreadBranchState.data?.refs],
40 );
41
42 return {
43 gitOperationLabel: gitActionState.currentLabel,
44 sourceControlDiscovery,
45 selectedThreadBranches,
46 selectedThreadBranchesLoading: selectedThreadBranchState.isPending,
47 };
48}
apps/mobile/src/state/use-selected-thread-requests.ts created+176
...@@ -0,0 +1,176 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useMemo, useState } from "react";
3
4import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts";
5import { Atom } from "effect/unstable/reactivity";
6
7import { scopedRequestKey } from "../lib/scopedEntities";
8import {
9 buildPendingUserInputAnswers,
10 derivePendingApprovals,
11 derivePendingUserInputs,
12 setPendingUserInputCustomAnswer,
13 type PendingUserInputDraftAnswer,
14} from "../lib/threadActivity";
15import { uuidv4 } from "../lib/uuid";
16import { appAtomRegistry } from "./atom-registry";
17import { getEnvironmentClient } from "./environment-session-registry";
18import { useSelectedThreadDetail } from "./use-thread-detail";
19import { useThreadSelection } from "./use-thread-selection";
20
21const userInputDraftsByRequestKeyAtom = Atom.make<
22 Record<string, Record<string, PendingUserInputDraftAnswer>>
23>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts"));
24
25function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void {
26 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
27 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
28 ...current,
29 [requestKey]: {
30 ...current[requestKey],
31 [questionId]: {
32 selectedOptionLabel: label,
33 },
34 },
35 });
36}
37
38function setUserInputDraftCustomAnswer(
39 requestKey: string,
40 questionId: string,
41 customAnswer: string,
42): void {
43 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
44 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
45 ...current,
46 [requestKey]: {
47 ...current[requestKey],
48 [questionId]: setPendingUserInputCustomAnswer(
49 current[requestKey]?.[questionId],
50 customAnswer,
51 ),
52 },
53 });
54}
55
56export function useSelectedThreadRequests() {
57 const { selectedThread: selectedThreadShell } = useThreadSelection();
58 const selectedThread = useSelectedThreadDetail();
59 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
60 const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null);
61 const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>(
62 null,
63 );
64
65 const activePendingApprovals = useMemo(
66 () => (selectedThread ? derivePendingApprovals(selectedThread.activities) : []),
67 [selectedThread],
68 );
69 const activePendingApproval = activePendingApprovals[0] ?? null;
70 const activePendingUserInputs = useMemo(
71 () => (selectedThread ? derivePendingUserInputs(selectedThread.activities) : []),
72 [selectedThread],
73 );
74 const activePendingUserInput = activePendingUserInputs[0] ?? null;
75 const activePendingUserInputDrafts =
76 activePendingUserInput && selectedThreadShell
77 ? (userInputDraftsByRequestKey[
78 scopedRequestKey(selectedThreadShell.environmentId, activePendingUserInput.requestId)
79 ] ?? {})
80 : {};
81 const activePendingUserInputAnswers = activePendingUserInput
82 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts)
83 : null;
84
85 const onSelectUserInputOption = useCallback(
86 (requestId: ApprovalRequestId, questionId: string, label: string) => {
87 if (!selectedThreadShell) {
88 return;
89 }
90
91 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
92 setUserInputDraftOption(requestKey, questionId, label);
93 },
94 [selectedThreadShell],
95 );
96
97 const onChangeUserInputCustomAnswer = useCallback(
98 (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => {
99 if (!selectedThreadShell) {
100 return;
101 }
102
103 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
104 setUserInputDraftCustomAnswer(requestKey, questionId, customAnswer);
105 },
106 [selectedThreadShell],
107 );
108
109 const onRespondToApproval = useCallback(
110 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
111 if (!selectedThreadShell) {
112 return;
113 }
114
115 const client = getEnvironmentClient(selectedThreadShell.environmentId);
116 if (!client) {
117 return;
118 }
119
120 setRespondingApprovalId(requestId);
121 try {
122 await client.orchestration.dispatchCommand({
123 type: "thread.approval.respond",
124 commandId: CommandId.make(uuidv4()),
125 threadId: selectedThreadShell.id,
126 requestId,
127 decision,
128 createdAt: new Date().toISOString(),
129 });
130 } finally {
131 setRespondingApprovalId((current) => (current === requestId ? null : current));
132 }
133 },
134 [selectedThreadShell],
135 );
136
137 const onSubmitUserInput = useCallback(async () => {
138 if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) {
139 return;
140 }
141
142 const client = getEnvironmentClient(selectedThreadShell.environmentId);
143 if (!client) {
144 return;
145 }
146
147 setRespondingUserInputId(activePendingUserInput.requestId);
148 try {
149 await client.orchestration.dispatchCommand({
150 type: "thread.user-input.respond",
151 commandId: CommandId.make(uuidv4()),
152 threadId: selectedThreadShell.id,
153 requestId: activePendingUserInput.requestId,
154 answers: activePendingUserInputAnswers,
155 createdAt: new Date().toISOString(),
156 });
157 } finally {
158 setRespondingUserInputId((current) =>
159 current === activePendingUserInput.requestId ? null : current,
160 );
161 }
162 }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]);
163
164 return {
165 activePendingApproval,
166 activePendingUserInput,
167 activePendingUserInputDrafts,
168 activePendingUserInputAnswers,
169 respondingApprovalId,
170 respondingUserInputId,
171 onRespondToApproval,
172 onSelectUserInputOption,
173 onChangeUserInputCustomAnswer,
174 onSubmitUserInput,
175 };
176}
apps/mobile/src/state/use-selected-thread-worktree.ts created+24
...@@ -0,0 +1,24 @@
1import { useMemo } from "react";
2
3import { useSelectedThreadDetail } from "./use-thread-detail";
4import { useThreadSelection } from "./use-thread-selection";
5import { resolvePreferredThreadWorktreePath } from "../features/terminal/terminalLaunchContext";
6
7export function useSelectedThreadWorktree() {
8 const { selectedThread, selectedThreadProject } = useThreadSelection();
9 const selectedThreadDetail = useSelectedThreadDetail();
10
11 const selectedThreadWorktreePath = useMemo(
12 () =>
13 resolvePreferredThreadWorktreePath({
14 threadShellWorktreePath: selectedThread?.worktreePath ?? null,
15 threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
16 }),
17 [selectedThread?.worktreePath, selectedThreadDetail?.worktreePath],
18 );
19
20 return {
21 selectedThreadWorktreePath,
22 selectedThreadCwd: selectedThreadWorktreePath ?? selectedThreadProject?.workspaceRoot ?? null,
23 };
24}
apps/mobile/src/state/use-shell-snapshot.ts created+111
...@@ -0,0 +1,111 @@
1import * as Arr from "effect/Array";
2import * as Order from "effect/Order";
3import { useAtomValue } from "@effect/atom-react";
4import { Atom } from "effect/unstable/reactivity";
5import {
6 EMPTY_SHELL_SNAPSHOT_ATOM,
7 EMPTY_SHELL_SNAPSHOT_STATE,
8 createShellSnapshotManager,
9 getShellSnapshotTargetKey,
10 shellSnapshotStateAtom,
11 type ShellSnapshotState,
12} from "@t3tools/client-runtime";
13import type { EnvironmentId } from "@t3tools/contracts";
14import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
15
16import { appAtomRegistry } from "./atom-registry";
17import type { CachedShellSnapshot } from "../lib/storage";
18
19const cachedShellSnapshotMetadataAtom = Atom.make<
20 Readonly<Record<EnvironmentId, { readonly snapshotReceivedAt: string }>>
21>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:cached-shell-snapshot-metadata"));
22
23export const shellSnapshotManager = createShellSnapshotManager({
24 getRegistry: () => appAtomRegistry,
25});
26
27export function hydrateCachedShellSnapshot(cached: CachedShellSnapshot): void {
28 shellSnapshotManager.syncSnapshot({ environmentId: cached.environmentId }, cached.snapshot);
29 appAtomRegistry.set(cachedShellSnapshotMetadataAtom, {
30 ...appAtomRegistry.get(cachedShellSnapshotMetadataAtom),
31 [cached.environmentId]: {
32 snapshotReceivedAt: cached.snapshotReceivedAt,
33 },
34 });
35}
36
37export function markShellSnapshotLive(environmentId: EnvironmentId): void {
38 const current = appAtomRegistry.get(cachedShellSnapshotMetadataAtom);
39 if (current[environmentId] === undefined) {
40 return;
41 }
42
43 const next = { ...current };
44 delete next[environmentId];
45 appAtomRegistry.set(cachedShellSnapshotMetadataAtom, next);
46}
47
48export function clearCachedShellSnapshotMetadata(environmentId: EnvironmentId): void {
49 markShellSnapshotLive(environmentId);
50}
51
52export function useCachedShellSnapshotMetadata(): Readonly<
53 Record<EnvironmentId, { readonly snapshotReceivedAt: string }>
54> {
55 return useAtomValue(cachedShellSnapshotMetadataAtom);
56}
57
58export function useShellSnapshot(environmentId: EnvironmentId | null): ShellSnapshotState {
59 const targetKey = getShellSnapshotTargetKey({ environmentId });
60 const state = useAtomValue(
61 targetKey !== null ? shellSnapshotStateAtom(targetKey) : EMPTY_SHELL_SNAPSHOT_ATOM,
62 );
63 return targetKey === null ? EMPTY_SHELL_SNAPSHOT_STATE : state;
64}
65
66export function useShellSnapshotStates(
67 environmentIds: ReadonlyArray<EnvironmentId>,
68): Readonly<Record<EnvironmentId, ShellSnapshotState>> {
69 const stableEnvironmentIds = useMemo(
70 () => Arr.sort(new Set(environmentIds), Order.String),
71 [environmentIds],
72 );
73 const snapshotCacheRef = useRef<Readonly<Record<EnvironmentId, ShellSnapshotState>>>({});
74
75 const subscribe = useCallback(
76 (onStoreChange: () => void) => {
77 const unsubs = stableEnvironmentIds.map((environmentId) =>
78 appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), onStoreChange),
79 );
80 return () => {
81 for (const unsub of unsubs) {
82 unsub();
83 }
84 };
85 },
86 [stableEnvironmentIds],
87 );
88
89 const getSnapshot = useCallback(() => {
90 const previous = snapshotCacheRef.current;
91 let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length;
92 const next: Record<EnvironmentId, ShellSnapshotState> = {};
93
94 for (const environmentId of stableEnvironmentIds) {
95 const snapshot = shellSnapshotManager.getSnapshot({ environmentId });
96 next[environmentId] = snapshot;
97 if (!hasChanged && previous[environmentId] !== snapshot) {
98 hasChanged = true;
99 }
100 }
101
102 if (!hasChanged) {
103 return previous;
104 }
105
106 snapshotCacheRef.current = next;
107 return next;
108 }, [stableEnvironmentIds]);
109
110 return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
111}
apps/mobile/src/state/use-source-control-discovery.ts created+78
...@@ -0,0 +1,78 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
4 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
5 type SourceControlDiscoveryClient,
6 type SourceControlDiscoveryState,
7 type SourceControlDiscoveryTarget,
8 createSourceControlDiscoveryManager,
9 getSourceControlDiscoveryTargetKey,
10 sourceControlDiscoveryStateAtom,
11} from "@t3tools/client-runtime";
12import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts";
13import { useEffect, useMemo } from "react";
14
15import { appAtomRegistry } from "./atom-registry";
16import {
17 getEnvironmentClient,
18 subscribeEnvironmentConnections,
19} from "./environment-session-registry";
20
21const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({
22 getRegistry: () => appAtomRegistry,
23 getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null,
24 subscribeClientChanges: subscribeEnvironmentConnections,
25});
26
27function sourceControlDiscoveryTargetForEnvironment(
28 environmentId: EnvironmentId | null,
29): SourceControlDiscoveryTarget<EnvironmentId> {
30 return { key: environmentId ?? null };
31}
32
33export function refreshSourceControlDiscoveryForEnvironment(
34 environmentId: EnvironmentId | null,
35 client?: SourceControlDiscoveryClient | null,
36): Promise<SourceControlDiscoveryResult | null> {
37 return sourceControlDiscoveryManager.refresh(
38 sourceControlDiscoveryTargetForEnvironment(environmentId),
39 client ?? undefined,
40 );
41}
42
43export function invalidateSourceControlDiscoveryForEnvironment(
44 environmentId: EnvironmentId | null,
45): void {
46 sourceControlDiscoveryManager.invalidate(
47 sourceControlDiscoveryTargetForEnvironment(environmentId),
48 );
49}
50
51export function resetSourceControlDiscoveryState(): void {
52 sourceControlDiscoveryManager.reset();
53}
54
55export function resetSourceControlDiscoveryStateForTests(): void {
56 resetSourceControlDiscoveryState();
57}
58
59export function useSourceControlDiscovery(
60 environmentId: EnvironmentId | null,
61): SourceControlDiscoveryState {
62 const target = useMemo(
63 () => sourceControlDiscoveryTargetForEnvironment(environmentId),
64 [environmentId],
65 );
66
67 useEffect(() => {
68 return sourceControlDiscoveryManager.watch(target);
69 }, [target]);
70
71 const targetKey = getSourceControlDiscoveryTargetKey(target);
72 const state = useAtomValue(
73 targetKey !== null
74 ? sourceControlDiscoveryStateAtom(targetKey)
75 : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
76 );
77 return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state;
78}
apps/mobile/src/state/use-terminal-session.ts created+84
...@@ -0,0 +1,84 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 createTerminalSessionManager,
4 EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM,
5 EMPTY_TERMINAL_SESSION_ATOM,
6 getKnownTerminalSessionTarget,
7 getKnownTerminalSessionListFilter,
8 knownTerminalSessionsAtom,
9 terminalSessionStateAtom,
10 type TerminalSessionTarget,
11 type TerminalSessionState,
12} from "@t3tools/client-runtime";
13import type {
14 EnvironmentId,
15 TerminalAttachInput,
16 TerminalAttachStreamEvent,
17 TerminalMetadataStreamEvent,
18 TerminalSessionSnapshot,
19} from "@t3tools/contracts";
20import { useMemo } from "react";
21
22import { appAtomRegistry } from "./atom-registry";
23
24export const terminalSessionManager = createTerminalSessionManager({
25 getRegistry: () => appAtomRegistry,
26});
27
28export function subscribeTerminalMetadata(input: {
29 readonly environmentId: EnvironmentId;
30 readonly client: {
31 readonly terminal: {
32 readonly onMetadata: (
33 listener: (event: TerminalMetadataStreamEvent) => void,
34 options?: { readonly onResubscribe?: () => void },
35 ) => () => void;
36 };
37 };
38}) {
39 return terminalSessionManager.subscribeMetadata(input);
40}
41
42export function attachTerminalSession(input: {
43 readonly environmentId: EnvironmentId;
44 readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"];
45 readonly terminal: TerminalAttachInput;
46 readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void;
47 readonly onEvent?: (event: TerminalAttachStreamEvent) => void;
48}) {
49 return terminalSessionManager.attach({
50 environmentId: input.environmentId,
51 client: input.client,
52 terminal: input.terminal,
53 ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}),
54 ...(input.onEvent ? { onEvent: input.onEvent } : {}),
55 });
56}
57
58export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState {
59 const target = getKnownTerminalSessionTarget(input);
60 return useAtomValue(
61 target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM,
62 );
63}
64
65export function useTerminalSessionTarget(input: TerminalSessionTarget) {
66 return useMemo(
67 () => ({
68 environmentId: input.environmentId,
69 threadId: input.threadId,
70 terminalId: input.terminalId,
71 }),
72 [input.environmentId, input.threadId, input.terminalId],
73 );
74}
75
76export function useKnownTerminalSessions(input: {
77 readonly environmentId: TerminalSessionTarget["environmentId"];
78 readonly threadId: TerminalSessionTarget["threadId"];
79}) {
80 const filter = getKnownTerminalSessionListFilter(input);
81 return useAtomValue(
82 filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM,
83 );
84}
apps/mobile/src/state/use-thread-composer-state.ts created+469
...@@ -0,0 +1,469 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo } from "react";
3
4import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
5import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
6import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
7import { Atom } from "effect/unstable/reactivity";
8
9import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
10import {
11 convertPastedImagesToAttachments,
12 pasteComposerClipboard,
13 pickComposerImages,
14} from "../lib/composerImages";
15import type { DraftComposerImageAttachment } from "../lib/composerImages";
16import { scopedThreadKey } from "../lib/scopedEntities";
17import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity";
18import { appAtomRegistry } from "../state/atom-registry";
19import { getEnvironmentClient } from "./environment-session-registry";
20import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types";
21import {
22 setPendingConnectionError,
23 useRemoteConnectionStatus,
24} from "../state/use-remote-environment-registry";
25import { useRemoteCatalog } from "../state/use-remote-catalog";
26import { useSelectedThreadDetail } from "../state/use-thread-detail";
27import { useThreadSelection } from "../state/use-thread-selection";
28
29const draftMessageByThreadKeyAtom = Atom.make<Record<string, string>>({}).pipe(
30 Atom.keepAlive,
31 Atom.withLabel("mobile:thread-composer:draft-message"),
32);
33
34const draftAttachmentsByThreadKeyAtom = Atom.make<
35 Record<string, ReadonlyArray<DraftComposerImageAttachment>>
36>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:draft-attachments"));
37
38const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
39 Atom.keepAlive,
40 Atom.withLabel("mobile:thread-composer:dispatching-message-id"),
41);
42
43const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<QueuedThreadMessage>>>(
44 {},
45).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages"));
46
47function setDraftMessage(threadKey: string, value: string): void {
48 const current = appAtomRegistry.get(draftMessageByThreadKeyAtom);
49 appAtomRegistry.set(draftMessageByThreadKeyAtom, {
50 ...current,
51 [threadKey]: value,
52 });
53}
54
55function appendDraftAttachments(
56 threadKey: string,
57 attachments: ReadonlyArray<DraftComposerImageAttachment>,
58): void {
59 const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom);
60 appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, {
61 ...current,
62 [threadKey]: [...(current[threadKey] ?? []), ...attachments],
63 });
64}
65
66function appendDraftMessage(threadKey: string, value: string): void {
67 const current = appAtomRegistry.get(draftMessageByThreadKeyAtom);
68 appAtomRegistry.set(draftMessageByThreadKeyAtom, {
69 ...current,
70 [threadKey]: `${current[threadKey] ?? ""}${value}`,
71 });
72}
73
74export function appendReviewCommentToDraft(input: {
75 readonly environmentId: EnvironmentId;
76 readonly threadId: ThreadId;
77 readonly text: string;
78 readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>;
79}): void {
80 const threadKey = scopedThreadKey(input.environmentId, input.threadId);
81 const current = appAtomRegistry.get(draftMessageByThreadKeyAtom);
82 const existing = current[threadKey] ?? "";
83 const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : "";
84 appAtomRegistry.set(draftMessageByThreadKeyAtom, {
85 ...current,
86 [threadKey]: `${existing}${separator}${input.text}`,
87 });
88 if (input.attachments && input.attachments.length > 0) {
89 appendDraftAttachments(threadKey, input.attachments);
90 }
91}
92
93export function useThreadDraftForThread(input: {
94 readonly environmentId?: EnvironmentId;
95 readonly threadId?: ThreadId;
96}) {
97 const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom);
98 const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom);
99 const threadKey =
100 input.environmentId && input.threadId
101 ? scopedThreadKey(input.environmentId, input.threadId)
102 : null;
103
104 return {
105 draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "",
106 draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [],
107 };
108}
109
110function clearDraft(threadKey: string): void {
111 const draftMessages = appAtomRegistry.get(draftMessageByThreadKeyAtom);
112 const draftAttachments = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom);
113 appAtomRegistry.set(draftMessageByThreadKeyAtom, {
114 ...draftMessages,
115 [threadKey]: "",
116 });
117 appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, {
118 ...draftAttachments,
119 [threadKey]: [],
120 });
121}
122
123function removeDraftImage(threadKey: string, imageId: string): void {
124 const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom);
125 appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, {
126 ...current,
127 [threadKey]: (current[threadKey] ?? []).filter((image) => image.id !== imageId),
128 });
129}
130
131function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
132 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
133}
134
135function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void {
136 const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom);
137 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current);
138}
139
140function enqueueQueuedMessage(message: QueuedThreadMessage): void {
141 const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom);
142 const threadKey = scopedThreadKey(message.environmentId, message.threadId);
143 appAtomRegistry.set(queuedMessagesByThreadKeyAtom, {
144 ...current,
145 [threadKey]: [...(current[threadKey] ?? []), message],
146 });
147}
148
149function removeQueuedMessage(
150 environmentId: EnvironmentId,
151 threadId: ThreadId,
152 queuedMessageId: MessageId,
153): void {
154 const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom);
155 const threadKey = scopedThreadKey(environmentId, threadId);
156 const existing = current[threadKey];
157 if (!existing) {
158 return;
159 }
160
161 const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId);
162 const next = { ...current };
163 if (nextQueue.length === 0) {
164 delete next[threadKey];
165 } else {
166 next[threadKey] = nextQueue;
167 }
168
169 appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next);
170}
171
172function useQueueDrain(input: {
173 readonly dispatchingQueuedMessageId: MessageId | null;
174 readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>;
175 readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>;
176 readonly environments: ReadonlyArray<ConnectedEnvironmentSummary>;
177 readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise<void>;
178}) {
179 const {
180 dispatchingQueuedMessageId,
181 environments,
182 queuedMessagesByThreadKey,
183 sendQueuedMessage,
184 threads,
185 } = input;
186
187 useEffect(() => {
188 if (dispatchingQueuedMessageId !== null) {
189 return;
190 }
191
192 for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) {
193 const nextQueuedMessage = queuedMessages[0];
194 if (!nextQueuedMessage) {
195 continue;
196 }
197
198 const thread = threads.find(
199 (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey,
200 );
201 if (!thread) {
202 continue;
203 }
204
205 const environment = environments.find(
206 (candidate) => candidate.environmentId === nextQueuedMessage.environmentId,
207 );
208 if (!environment || environment.connectionState !== "ready") {
209 continue;
210 }
211
212 const threadStatus = thread.session?.status;
213 if (threadStatus === "running" || threadStatus === "starting") {
214 continue;
215 }
216
217 void sendQueuedMessage(nextQueuedMessage);
218 return;
219 }
220 }, [
221 dispatchingQueuedMessageId,
222 environments,
223 queuedMessagesByThreadKey,
224 sendQueuedMessage,
225 threads,
226 ]);
227}
228
229export function useThreadComposerState() {
230 const { connectedEnvironments } = useRemoteConnectionStatus();
231 const { threads } = useRemoteCatalog();
232 const { selectedThread: selectedThreadShell } = useThreadSelection();
233 const selectedThread = useSelectedThreadDetail();
234 const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom);
235 const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom);
236 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
237 const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom);
238
239 const selectedThreadKey = selectedThreadShell
240 ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id)
241 : null;
242 const selectedThreadQueuedMessages = useMemo(
243 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
244 [queuedMessagesByThreadKey, selectedThreadKey],
245 );
246
247 const selectedThreadFeed = useMemo(
248 () =>
249 selectedThread
250 ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId)
251 : [],
252 [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages],
253 );
254
255 const draftMessage = selectedThreadKey ? (draftMessageByThreadKey[selectedThreadKey] ?? "") : "";
256 const draftAttachments = selectedThreadKey
257 ? (draftAttachmentsByThreadKey[selectedThreadKey] ?? [])
258 : [];
259 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
260
261 const selectedThreadSessionActivity = useMemo(() => {
262 if (!selectedThread?.session) {
263 return null;
264 }
265
266 return {
267 orchestrationStatus: selectedThread.session.status,
268 activeTurnId: selectedThread.session.activeTurnId ?? undefined,
269 };
270 }, [selectedThread]);
271
272 const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null;
273 const activeWorkStartedAt = useMemo(() => {
274 if (!selectedThread) {
275 return null;
276 }
277
278 return deriveActiveWorkStartedAt(
279 selectedThread.latestTurn,
280 selectedThreadSessionActivity,
281 queuedSendStartedAt,
282 );
283 }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]);
284
285 const activeThreadBusy =
286 !!selectedThread &&
287 (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting");
288
289 const sendQueuedMessage = useCallback(
290 async (queuedMessage: QueuedThreadMessage) => {
291 const client = getEnvironmentClient(queuedMessage.environmentId);
292 const thread = threads.find(
293 (candidate) =>
294 candidate.environmentId === queuedMessage.environmentId &&
295 candidate.id === queuedMessage.threadId,
296 );
297 if (!client || !thread) {
298 return;
299 }
300
301 beginDispatchingQueuedMessage(queuedMessage.messageId);
302 try {
303 await client.orchestration.dispatchCommand({
304 type: "thread.turn.start",
305 commandId: queuedMessage.commandId,
306 threadId: queuedMessage.threadId,
307 message: {
308 messageId: queuedMessage.messageId,
309 role: "user",
310 text: queuedMessage.text,
311 attachments: queuedMessage.attachments,
312 },
313 runtimeMode: thread.runtimeMode,
314 interactionMode: thread.interactionMode,
315 createdAt: queuedMessage.createdAt,
316 });
317
318 removeQueuedMessage(
319 queuedMessage.environmentId,
320 queuedMessage.threadId,
321 queuedMessage.messageId,
322 );
323 } catch (error) {
324 removeQueuedMessage(
325 queuedMessage.environmentId,
326 queuedMessage.threadId,
327 queuedMessage.messageId,
328 );
329 setPendingConnectionError(
330 error instanceof Error ? error.message : "Failed to send message.",
331 );
332 } finally {
333 finishDispatchingQueuedMessage(queuedMessage.messageId);
334 }
335 },
336 [threads],
337 );
338
339 useQueueDrain({
340 dispatchingQueuedMessageId,
341 queuedMessagesByThreadKey,
342 threads,
343 environments: connectedEnvironments,
344 sendQueuedMessage,
345 });
346
347 const onSendMessage = useCallback(() => {
348 if (!selectedThreadShell) {
349 return;
350 }
351
352 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
353 const text = (draftMessageByThreadKey[threadKey] ?? "").trim();
354 const attachments = draftAttachmentsByThreadKey[threadKey] ?? [];
355 if (text.length === 0 && attachments.length === 0) {
356 return;
357 }
358
359 const metadata = makeQueuedMessageMetadata();
360 enqueueQueuedMessage({
361 environmentId: selectedThreadShell.environmentId,
362 threadId: selectedThreadShell.id,
363 messageId: MessageId.make(metadata.messageId),
364 commandId: CommandId.make(metadata.commandId),
365 text,
366 attachments,
367 createdAt: metadata.createdAt,
368 });
369 clearDraft(threadKey);
370 }, [draftAttachmentsByThreadKey, draftMessageByThreadKey, selectedThreadShell]);
371
372 const onChangeDraftMessage = useCallback(
373 (value: string) => {
374 if (!selectedThreadShell) {
375 return;
376 }
377
378 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
379 setDraftMessage(threadKey, value);
380 },
381 [selectedThreadShell],
382 );
383
384 const onPickDraftImages = useCallback(async () => {
385 if (!selectedThreadShell) {
386 return;
387 }
388
389 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
390 const result = await pickComposerImages({
391 existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
392 });
393 if (result.images.length > 0) {
394 appendDraftAttachments(threadKey, result.images);
395 }
396 if (result.error) {
397 setPendingConnectionError(result.error);
398 }
399 }, [draftAttachmentsByThreadKey, selectedThreadShell]);
400
401 const onPasteIntoDraft = useCallback(async () => {
402 if (!selectedThreadShell) {
403 return;
404 }
405
406 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
407 const result = await pasteComposerClipboard({
408 existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
409 });
410 if (result.images.length > 0) {
411 appendDraftAttachments(threadKey, result.images);
412 }
413 if (result.text) {
414 appendDraftMessage(threadKey, result.text);
415 }
416 if (result.error) {
417 setPendingConnectionError(result.error);
418 }
419 }, [draftAttachmentsByThreadKey, selectedThreadShell]);
420
421 const onNativePasteImages = useCallback(
422 async (uris: ReadonlyArray<string>) => {
423 if (!selectedThreadShell || uris.length === 0) {
424 return;
425 }
426
427 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
428 try {
429 const images = await convertPastedImagesToAttachments({
430 uris,
431 existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
432 });
433 if (images.length > 0) {
434 appendDraftAttachments(threadKey, images);
435 }
436 } catch (error) {
437 console.error("[native paste] error converting images", error);
438 }
439 },
440 [draftAttachmentsByThreadKey, selectedThreadShell],
441 );
442
443 const onRemoveDraftImage = useCallback(
444 (imageId: string) => {
445 if (!selectedThreadShell) {
446 return;
447 }
448
449 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
450 removeDraftImage(threadKey, imageId);
451 },
452 [selectedThreadShell],
453 );
454
455 return {
456 selectedThreadFeed,
457 selectedThreadQueueCount,
458 activeWorkStartedAt,
459 draftMessage,
460 draftAttachments,
461 activeThreadBusy,
462 onChangeDraftMessage,
463 onPickDraftImages,
464 onPasteIntoDraft,
465 onNativePasteImages,
466 onRemoveDraftImage,
467 onSendMessage,
468 };
469}
apps/mobile/src/state/use-thread-detail.ts created+82
...@@ -0,0 +1,82 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 EMPTY_THREAD_DETAIL_ATOM,
4 EMPTY_THREAD_DETAIL_STATE,
5 createThreadDetailManager,
6 getThreadDetailTargetKey,
7 threadDetailStateAtom,
8 type ThreadDetailState,
9 type ThreadDetailTarget,
10} from "@t3tools/client-runtime";
11import { useEffect, useMemo } from "react";
12
13import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity";
14import { appAtomRegistry } from "./atom-registry";
15import {
16 getEnvironmentClient,
17 subscribeEnvironmentConnections,
18} from "./environment-session-registry";
19import { useThreadSelection } from "./use-thread-selection";
20
21function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean {
22 const thread = state.data;
23 if (!thread || state.isDeleted) {
24 return false;
25 }
26
27 if (thread.latestTurn?.sourceProposedPlan) {
28 return true;
29 }
30
31 const sessionStatus = thread.session?.status;
32 if (sessionStatus && sessionStatus !== "idle" && sessionStatus !== "stopped") {
33 return true;
34 }
35
36 return (
37 derivePendingApprovals(thread.activities).length > 0 ||
38 derivePendingUserInputs(thread.activities).length > 0
39 );
40}
41
42const threadDetailManager = createThreadDetailManager({
43 getRegistry: () => appAtomRegistry,
44 getClient: (environmentId) => {
45 const client = getEnvironmentClient(environmentId);
46 return client ? client.orchestration : null;
47 },
48 getClientIdentity: (environmentId) => {
49 return getEnvironmentClient(environmentId) ? environmentId : null;
50 },
51 subscribeClientChanges: subscribeEnvironmentConnections,
52 retention: {
53 idleTtlMs: 5 * 60 * 1_000,
54 maxRetainedEntries: 24,
55 shouldKeepWarm: (_target, state) => shouldKeepThreadDetailWarm(state),
56 },
57});
58
59export function useThreadDetail(target: ThreadDetailTarget): ThreadDetailState {
60 const { environmentId, threadId } = target;
61 const targetKey = getThreadDetailTargetKey(target);
62
63 useEffect(
64 () => threadDetailManager.watch({ environmentId, threadId }),
65 [environmentId, threadId],
66 );
67
68 const state = useAtomValue(
69 targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM,
70 );
71 return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state;
72}
73
74export function useSelectedThreadDetail() {
75 const { selectedThread } = useThreadSelection();
76 const state = useThreadDetail({
77 environmentId: selectedThread?.environmentId ?? null,
78 threadId: selectedThread?.id ?? null,
79 });
80
81 return useMemo(() => state.data, [state.data]);
82}
apps/mobile/src/state/use-thread-selection.ts created+95
...@@ -0,0 +1,95 @@
1import { useLocalSearchParams } from "expo-router";
2import { useMemo } from "react";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4
5import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
6import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime";
7import { useRemoteCatalog } from "./use-remote-catalog";
8import { useRemoteEnvironmentState } from "./use-remote-environment-registry";
9
10function firstRouteParam(value: string | string[] | undefined): string | null {
11 if (Array.isArray(value)) {
12 return value[0] ?? null;
13 }
14
15 return value ?? null;
16}
17
18function deriveSelectedThread(
19 selectedThreadRef: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null,
20 threads: ReadonlyArray<EnvironmentScopedThreadShell>,
21): EnvironmentScopedThreadShell | null {
22 if (!selectedThreadRef) {
23 return null;
24 }
25
26 return (
27 threads.find(
28 (thread) =>
29 thread.environmentId === selectedThreadRef.environmentId &&
30 thread.id === selectedThreadRef.threadId,
31 ) ?? null
32 );
33}
34
35function deriveSelectedThreadProject(
36 selectedThread: EnvironmentScopedThreadShell | null,
37 projects: ReadonlyArray<EnvironmentScopedProjectShell>,
38): EnvironmentScopedProjectShell | null {
39 if (!selectedThread) {
40 return null;
41 }
42
43 return (
44 projects.find(
45 (project) =>
46 project.environmentId === selectedThread.environmentId &&
47 project.id === selectedThread.projectId,
48 ) ?? null
49 );
50}
51
52export function useThreadSelection() {
53 const { projects, threads } = useRemoteCatalog();
54 const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState();
55 const params = useLocalSearchParams<{
56 environmentId?: string | string[];
57 threadId?: string | string[];
58 }>();
59 const selectedThreadRef = useMemo(() => {
60 const environmentId = firstRouteParam(params.environmentId);
61 const threadId = firstRouteParam(params.threadId);
62 if (!environmentId || !threadId) {
63 return null;
64 }
65
66 return {
67 environmentId: EnvironmentId.make(environmentId),
68 threadId: ThreadId.make(threadId),
69 };
70 }, [params.environmentId, params.threadId]);
71 const selectedThread = useMemo(
72 () => deriveSelectedThread(selectedThreadRef, threads),
73 [selectedThreadRef, threads],
74 );
75
76 const selectedThreadProject = useMemo(
77 () => deriveSelectedThreadProject(selectedThread, projects),
78 [projects, selectedThread],
79 );
80
81 const selectedEnvironmentConnection = selectedThread
82 ? (savedConnectionsById[selectedThread.environmentId] ?? null)
83 : null;
84 const selectedEnvironmentRuntime = selectedThread
85 ? (environmentStateById[selectedThread.environmentId] ?? null)
86 : null;
87
88 return {
89 selectedThreadRef,
90 selectedThread,
91 selectedThreadProject,
92 selectedEnvironmentConnection,
93 selectedEnvironmentRuntime,
94 };
95}
apps/mobile/src/state/use-vcs-action-state.ts created+162
...@@ -0,0 +1,162 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type VcsActionState,
4 type VcsActionTarget,
5 EMPTY_VCS_ACTION_ATOM,
6 EMPTY_VCS_ACTION_STATE,
7 createVcsActionManager,
8 getVcsActionTargetKey,
9 vcsActionStateAtom,
10} from "@t3tools/client-runtime";
11import { useCallback, useEffect, useRef, useState } from "react";
12
13import { uuidv4 } from "../lib/uuid";
14import { appAtomRegistry } from "./atom-registry";
15import { getEnvironmentClient } from "./environment-session-registry";
16
17export const vcsActionManager = createVcsActionManager({
18 getRegistry: () => appAtomRegistry,
19 getClient: (environmentId) => {
20 const client = getEnvironmentClient(environmentId);
21 return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null;
22 },
23 getActionId: uuidv4,
24});
25
26export function useVcsActionState(target: VcsActionTarget): VcsActionState {
27 const targetKey = getVcsActionTargetKey(target);
28 const state = useAtomValue(
29 targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM,
30 );
31 return targetKey === null ? EMPTY_VCS_ACTION_STATE : state;
32}
33
34// ---------------------------------------------------------------------------
35// Git action result notification
36// ---------------------------------------------------------------------------
37
38export interface GitActionResultNotification {
39 readonly type: "success" | "error";
40 readonly title: string;
41 readonly description?: string;
42 readonly prUrl?: string;
43}
44
45const RESULT_DISMISS_MS = 5_000;
46
47type ResultListener = (result: GitActionResultNotification | null) => void;
48const resultListeners = new Set<ResultListener>();
49let currentResult: GitActionResultNotification | null = null;
50let dismissTimer: ReturnType<typeof setTimeout> | null = null;
51
52function broadcast(result: GitActionResultNotification | null): void {
53 currentResult = result;
54 for (const listener of resultListeners) {
55 listener(result);
56 }
57}
58
59export function showGitActionResult(result: GitActionResultNotification): void {
60 if (dismissTimer) clearTimeout(dismissTimer);
61 broadcast(result);
62 dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS);
63}
64
65export function dismissGitActionResult(): void {
66 if (dismissTimer) clearTimeout(dismissTimer);
67 broadcast(null);
68}
69
70export function useGitActionResultNotification(): {
71 readonly result: GitActionResultNotification | null;
72 readonly dismiss: () => void;
73} {
74 const [result, setResult] = useState<GitActionResultNotification | null>(currentResult);
75
76 useEffect(() => {
77 resultListeners.add(setResult);
78 setResult(currentResult);
79 return () => {
80 resultListeners.delete(setResult);
81 };
82 }, []);
83
84 return { result, dismiss: dismissGitActionResult };
85}
86
87// ---------------------------------------------------------------------------
88// Unified git action progress (combines running state + result notification)
89// ---------------------------------------------------------------------------
90
91export type GitActionProgressPhase = "idle" | "running" | "success" | "error";
92
93export interface GitActionProgress {
94 readonly phase: GitActionProgressPhase;
95 readonly label: string | null;
96 readonly description: string | null;
97 readonly prUrl?: string;
98}
99
100const EMPTY_PROGRESS: GitActionProgress = {
101 phase: "idle",
102 label: null,
103 description: null,
104};
105
106function formatElapsedSeconds(ms: number | null): string | null {
107 if (ms === null) return null;
108 const elapsed = Math.max(0, Math.floor((Date.now() - ms) / 1000));
109 if (elapsed < 2) return null;
110 return `Running for ${elapsed}s`;
111}
112
113export function useGitActionProgress(target: VcsActionTarget): GitActionProgress {
114 const actionState = useVcsActionState(target);
115 const { result } = useGitActionResultNotification();
116
117 const [, forceUpdate] = useState(0);
118 const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
119
120 const startElapsedTimer = useCallback(() => {
121 if (intervalRef.current) return;
122 intervalRef.current = setInterval(() => forceUpdate((n) => n + 1), 1000);
123 }, []);
124
125 const stopElapsedTimer = useCallback(() => {
126 if (intervalRef.current) {
127 clearInterval(intervalRef.current);
128 intervalRef.current = null;
129 }
130 }, []);
131
132 useEffect(() => {
133 if (actionState.isRunning) {
134 startElapsedTimer();
135 } else {
136 stopElapsedTimer();
137 }
138 return stopElapsedTimer;
139 }, [actionState.isRunning, startElapsedTimer, stopElapsedTimer]);
140
141 if (actionState.isRunning) {
142 const description =
143 actionState.lastOutputLine ??
144 formatElapsedSeconds(actionState.hookStartedAtMs ?? actionState.phaseStartedAtMs);
145 return {
146 phase: "running",
147 label: actionState.currentLabel,
148 description,
149 };
150 }
151
152 if (result) {
153 return {
154 phase: result.type,
155 label: result.title,
156 description: result.description ?? null,
157 prUrl: result.prUrl,
158 };
159 }
160
161 return EMPTY_PROGRESS;
162}
apps/mobile/src/state/use-vcs-refs.ts created+51
...@@ -0,0 +1,51 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useEffect, useMemo } from "react";
3import {
4 type VcsRefState,
5 type VcsRefTarget,
6 EMPTY_VCS_REF_ATOM,
7 EMPTY_VCS_REF_STATE,
8 createVcsRefManager,
9 getVcsRefTargetKey,
10 vcsRefStateAtom,
11} from "@t3tools/client-runtime";
12
13import { appAtomRegistry } from "./atom-registry";
14import {
15 getEnvironmentClient,
16 subscribeEnvironmentConnections,
17} from "./environment-session-registry";
18
19const VCS_REF_LIST_LIMIT = 100;
20const VCS_REF_STALE_TIME_MS = 5_000;
21
22export const vcsRefManager = createVcsRefManager({
23 getRegistry: () => appAtomRegistry,
24 getClient: (environmentId) => {
25 const client = getEnvironmentClient(environmentId);
26 return client ? client.vcs : null;
27 },
28 subscribeClientChanges: subscribeEnvironmentConnections,
29 watchLimit: VCS_REF_LIST_LIMIT,
30 staleTimeMs: VCS_REF_STALE_TIME_MS,
31 onBackgroundError: (error) => {
32 console.warn("[vcs-refs] background refresh failed", error);
33 },
34});
35
36export function useVcsRefs(target: VcsRefTarget): VcsRefState {
37 const stableTarget = useMemo(
38 () => ({
39 environmentId: target.environmentId,
40 cwd: target.cwd,
41 query: target.query ?? null,
42 }),
43 [target.cwd, target.environmentId, target.query],
44 );
45 const targetKey = getVcsRefTargetKey(stableTarget);
46
47 useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]);
48
49 const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM);
50 return targetKey === null ? EMPTY_VCS_REF_STATE : state;
51}
apps/mobile/src/state/use-vcs-status.ts created+62
...@@ -0,0 +1,62 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type VcsStatusState,
4 type VcsStatusTarget,
5 EMPTY_VCS_STATUS_ATOM,
6 EMPTY_VCS_STATUS_STATE,
7 createVcsStatusManager,
8 getVcsStatusTargetKey,
9 vcsStatusStateAtom,
10} from "@t3tools/client-runtime";
11import { useEffect } from "react";
12
13import { appAtomRegistry } from "./atom-registry";
14import {
15 getEnvironmentClient,
16 subscribeEnvironmentConnections,
17} from "./environment-session-registry";
18
19/**
20 * Singleton VCS status manager for the mobile app.
21 *
22 * Uses ref-counted `onStatus` subscriptions (one per unique cwd)
23 * rather than one-shot `refreshStatus` RPCs. Multiple threads
24 * sharing the same cwd (i.e. same project, no worktree) share
25 * a single WS subscription.
26 *
27 * `subscribeClientChanges` ensures subscriptions are established
28 * even when the WS connection isn't ready at mount time, and
29 * re-established on reconnection.
30 */
31export const vcsStatusManager = createVcsStatusManager({
32 getRegistry: () => appAtomRegistry,
33 getClient: (environmentId) => {
34 const client = getEnvironmentClient(environmentId);
35 return client ? client.vcs : null;
36 },
37 getClientIdentity: (environmentId) => {
38 return getEnvironmentClient(environmentId) ? environmentId : null;
39 },
40 subscribeClientChanges: subscribeEnvironmentConnections,
41});
42
43/**
44 * Subscribe to live VCS status for a target (environmentId + cwd).
45 *
46 * Mirrors the web's `useVcsStatus` hook. Automatically subscribes
47 * on mount, ref-counts shared cwds, and unsubscribes on unmount.
48 * Returns reactive `VcsStatusState` via Effect atoms.
49 */
50export function useVcsStatus(target: VcsStatusTarget): VcsStatusState {
51 const targetKey = getVcsStatusTargetKey(target);
52
53 useEffect(
54 () => vcsStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }),
55 [target.environmentId, target.cwd],
56 );
57
58 const state = useAtomValue(
59 targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM,
60 );
61 return targetKey === null ? EMPTY_VCS_STATUS_STATE : state;
62}
apps/mobile/tsconfig.json created+7
...@@ -0,0 +1,7 @@
1{
2 "extends": "expo/tsconfig.base",
3 "compilerOptions": {
4 "allowImportingTsExtensions": true,
5 "strict": true
6 }
7}
apps/mobile/uniwind-types.d.ts created+10
...@@ -0,0 +1,10 @@
1// NOTE: This file is generated by uniwind and it should not be edited manually.
2/// <reference types="uniwind/types" />
3
4declare module 'uniwind' {
5 export interface UniwindConfig {
6 themes: readonly ['light', 'dark']
7 }
8}
9
10export {}
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+5
...@@ -54,12 +54,14 @@ describe("ProjectSetupScriptRunner", () => {...@@ -54,12 +54,14 @@ describe("ProjectSetupScriptRunner", () => {
54 Layer.provideMerge(54 Layer.provideMerge(
55 Layer.succeed(TerminalManager, {55 Layer.succeed(TerminalManager, {
56 open,56 open,
57 attachStream: () => Effect.die(new Error("unused")),
57 write,58 write,
58 resize: () => Effect.void,59 resize: () => Effect.void,
59 clear: () => Effect.void,60 clear: () => Effect.void,
60 restart: () => Effect.die(new Error("unused")),61 restart: () => Effect.die(new Error("unused")),
61 close: () => Effect.void,62 close: () => Effect.void,
62 subscribe: () => Effect.succeed(() => undefined),63 subscribe: () => Effect.succeed(() => undefined),
64 subscribeMetadata: () => Effect.succeed(() => undefined),
63 }),65 }),
64 ),66 ),
65 ),67 ),
...@@ -92,6 +94,7 @@ describe("ProjectSetupScriptRunner", () => {...@@ -92,6 +94,7 @@ describe("ProjectSetupScriptRunner", () => {
92 history: "",94 history: "",
93 exitCode: null,95 exitCode: null,
94 exitSignal: null,96 exitSignal: null,
97 label: "setup-setup",
95 updatedAt: "2026-01-01T00:00:00.000Z",98 updatedAt: "2026-01-01T00:00:00.000Z",
96 }),99 }),
97 );100 );
...@@ -113,12 +116,14 @@ describe("ProjectSetupScriptRunner", () => {...@@ -113,12 +116,14 @@ describe("ProjectSetupScriptRunner", () => {
113 Layer.provideMerge(116 Layer.provideMerge(
114 Layer.succeed(TerminalManager, {117 Layer.succeed(TerminalManager, {
115 open,118 open,
119 attachStream: () => Effect.die(new Error("unused")),
116 write,120 write,
117 resize: () => Effect.void,121 resize: () => Effect.void,
118 clear: () => Effect.void,122 clear: () => Effect.void,
119 restart: () => Effect.die(new Error("unused")),123 restart: () => Effect.die(new Error("unused")),
120 close: () => Effect.void,124 close: () => Effect.void,
121 subscribe: () => Effect.succeed(() => undefined),125 subscribe: () => Effect.succeed(() => undefined),
126 subscribeMetadata: () => Effect.succeed(() => undefined),
122 }),127 }),
123 ),128 ),
124 ),129 ),
apps/server/src/review/ReviewService.test.ts created+76
...@@ -0,0 +1,76 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6
7import { ServerConfig } from "../config.ts";
8import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
9import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
10import * as ReviewService from "./ReviewService.ts";
11
12function makeLayer(input: {
13 readonly workspaceRoot: string;
14 readonly baseDir: string;
15 readonly detectCalls?: Array<{ readonly cwd: string }>;
16}) {
17 return ReviewService.layer.pipe(
18 Layer.provide(
19 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
20 get: () => Effect.die("unexpected VCS registry get"),
21 resolve: () => Effect.die("unexpected VCS registry resolve"),
22 detect: (request) =>
23 Effect.sync(() => {
24 input.detectCalls?.push({ cwd: request.cwd });
25 return null;
26 }),
27 }),
28 ),
29 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
30 Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)),
31 Layer.provideMerge(NodeServices.layer),
32 );
33}
34
35describe("ReviewService", () => {
36 it.effect("rejects diff preview cwd outside the configured workspace roots", () =>
37 Effect.gen(function* () {
38 const fs = yield* FileSystem.FileSystem;
39 const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
40 const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" });
41 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
42 const detectCalls: Array<{ readonly cwd: string }> = [];
43
44 const error = yield* Effect.gen(function* () {
45 const review = yield* ReviewService.ReviewService;
46 return yield* review.getDiffPreview({ cwd: outsideRoot }).pipe(Effect.flip);
47 }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls })));
48
49 assert.strictEqual(error._tag, "VcsRepositoryDetectionError");
50 assert.strictEqual(error.operation, "ReviewService.getDiffPreview");
51 assert.match(
52 "detail" in error ? error.detail : "",
53 /must stay within the configured workspace root/,
54 );
55 assert.deepStrictEqual(detectCalls, []);
56 }).pipe(Effect.provide(NodeServices.layer)),
57 );
58
59 it.effect("allows diff preview cwd inside the configured workspace root", () =>
60 Effect.gen(function* () {
61 const fs = yield* FileSystem.FileSystem;
62 const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
63 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
64 const detectCalls: Array<{ readonly cwd: string }> = [];
65
66 const result = yield* Effect.gen(function* () {
67 const review = yield* ReviewService.ReviewService;
68 return yield* review.getDiffPreview({ cwd: workspaceRoot });
69 }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls })));
70
71 assert.strictEqual(result.cwd, workspaceRoot);
72 assert.deepStrictEqual(result.sources, []);
73 assert.deepStrictEqual(detectCalls, [{ cwd: workspaceRoot }]);
74 }).pipe(Effect.provide(NodeServices.layer)),
75 );
76});
apps/server/src/review/ReviewService.ts created+101
...@@ -0,0 +1,101 @@
1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import {
9 VcsRepositoryDetectionError,
10 VcsUnsupportedOperationError,
11 type ReviewDiffPreviewError,
12 type ReviewDiffPreviewInput,
13 type ReviewDiffPreviewResult,
14} from "@t3tools/contracts";
15
16import { ServerConfig } from "../config.ts";
17import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
18import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
19
20export interface ReviewServiceShape {
21 readonly getDiffPreview: (
22 input: ReviewDiffPreviewInput,
23 ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>;
24}
25
26export class ReviewService extends Context.Service<ReviewService, ReviewServiceShape>()(
27 "t3/review/ReviewService",
28) {}
29
30export const make = Effect.fn("makeReviewService")(function* () {
31 const config = yield* ServerConfig;
32 const fileSystem = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;
34 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
35 const git = yield* GitVcsDriver.GitVcsDriver;
36
37 const canonicalizePath = (value: string) =>
38 fileSystem
39 .realPath(path.resolve(value))
40 .pipe(Effect.catch(() => Effect.succeed(path.resolve(value))));
41
42 const isWithinRoot = (candidate: string, root: string) => {
43 const relative = path.relative(root, candidate);
44 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
45 };
46
47 const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* (
48 cwd: string,
49 ) {
50 const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([
51 canonicalizePath(cwd),
52 canonicalizePath(config.cwd),
53 canonicalizePath(config.worktreesDir),
54 ]);
55
56 if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) {
57 return;
58 }
59
60 return yield* new VcsRepositoryDetectionError({
61 operation: "ReviewService.getDiffPreview",
62 cwd,
63 detail: "Review diff preview cwd must stay within the configured workspace root.",
64 });
65 });
66
67 const getDiffPreview: ReviewServiceShape["getDiffPreview"] = Effect.fn(
68 "ReviewService.getDiffPreview",
69 )(function* (input) {
70 yield* assertWorkspaceBoundCwd(input.cwd);
71
72 const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });
73 if (!handle) {
74 return {
75 cwd: input.cwd,
76 generatedAt: yield* DateTime.now,
77 sources: [],
78 };
79 }
80
81 const getDriverDiffPreview = handle.driver.getDiffPreview;
82 if (!getDriverDiffPreview) {
83 if (handle.kind === "git") {
84 return yield* git.getReviewDiffPreview(input);
85 }
86 return yield* new VcsUnsupportedOperationError({
87 operation: "ReviewService.getDiffPreview",
88 kind: handle.kind,
89 detail: `The ${handle.kind} VCS driver does not support review diff previews.`,
90 });
91 }
92
93 return yield* getDriverDiffPreview(input);
94 });
95
96 return ReviewService.of({
97 getDiffPreview,
98 });
99});
100
101export const layer = Layer.effect(ReviewService, make());
apps/server/src/server.test.ts+68-2
...@@ -112,6 +112,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";...@@ -112,6 +112,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
112import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";112import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
113import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";113import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
114import * as GitWorkflowService from "./git/GitWorkflowService.ts";114import * as GitWorkflowService from "./git/GitWorkflowService.ts";
115import * as ReviewService from "./review/ReviewService.ts";
115import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";116import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
116import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";117import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
117import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";118import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
...@@ -327,6 +328,7 @@ const buildAppUnderTest = (options?: {...@@ -327,6 +328,7 @@ const buildAppUnderTest = (options?: {
327 gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>;328 gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>;
328 gitManager?: Partial<GitManagerShape>;329 gitManager?: Partial<GitManagerShape>;
329 sourceControlRepositoryService?: Partial<SourceControlRepositoryService.SourceControlRepositoryServiceShape>;330 sourceControlRepositoryService?: Partial<SourceControlRepositoryService.SourceControlRepositoryServiceShape>;
331 reviewService?: Partial<ReviewService.ReviewServiceShape>;
330 vcsStatusBroadcaster?: Partial<VcsStatusBroadcaster.VcsStatusBroadcasterShape>;332 vcsStatusBroadcaster?: Partial<VcsStatusBroadcaster.VcsStatusBroadcasterShape>;
331 projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>;333 projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>;
332 terminalManager?: Partial<TerminalManagerShape>;334 terminalManager?: Partial<TerminalManagerShape>;
...@@ -497,6 +499,14 @@ const buildAppUnderTest = (options?: {...@@ -497,6 +499,14 @@ const buildAppUnderTest = (options?: {
497 const vcsProvisioningLayer = VcsProvisioningService.layer.pipe(499 const vcsProvisioningLayer = VcsProvisioningService.layer.pipe(
498 Layer.provide(vcsDriverRegistryLayer),500 Layer.provide(vcsDriverRegistryLayer),
499 );501 );
502 const reviewLayer = options?.layers?.reviewService
503 ? Layer.mock(ReviewService.ReviewService)({
504 ...options.layers.reviewService,
505 })
506 : ReviewService.layer.pipe(
507 Layer.provideMerge(gitVcsDriverLayer),
508 Layer.provide(vcsDriverRegistryLayer),
509 );
500 const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster510 const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster
501 ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({511 ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
502 ...options.layers.vcsStatusBroadcaster,512 ...options.layers.vcsStatusBroadcaster,
...@@ -611,6 +621,7 @@ const buildAppUnderTest = (options?: {...@@ -611,6 +621,7 @@ const buildAppUnderTest = (options?: {
611 Layer.provide(gitManagerLayer),621 Layer.provide(gitManagerLayer),
612 Layer.provide(gitVcsDriverLayer),622 Layer.provide(gitVcsDriverLayer),
613 Layer.provide(gitWorkflowLayer),623 Layer.provide(gitWorkflowLayer),
624 Layer.provide(reviewLayer),
614 Layer.provide(vcsProvisioningLayer),625 Layer.provide(vcsProvisioningLayer),
615 Layer.provide(626 Layer.provide(
616 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({627 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
...@@ -2492,7 +2503,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2492,7 +2503,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2492 it.effect("routes websocket rpc git methods", () =>2503 it.effect("routes websocket rpc git methods", () =>
2493 Effect.gen(function* () {2504 Effect.gen(function* () {
2494 yield* buildAppUnderTest({2505 yield* buildAppUnderTest({
2506 config: {
2507 cwd: "/tmp/repo",
2508 },
2495 layers: {2509 layers: {
2510 vcsDriver: {
2511 isInsideWorkTree: () => Effect.succeed(true),
2512 },
2496 gitManager: {2513 gitManager: {
2497 invalidateLocalStatus: () => Effect.void,2514 invalidateLocalStatus: () => Effect.void,
2498 invalidateRemoteStatus: () => Effect.void,2515 invalidateRemoteStatus: () => Effect.void,
...@@ -2629,8 +2646,49 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2629,8 +2646,49 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2629 createRef: (input) => Effect.succeed({ refName: input.refName }),2646 createRef: (input) => Effect.succeed({ refName: input.refName }),
2630 switchRef: (input) => Effect.succeed({ refName: input.refName }),2647 switchRef: (input) => Effect.succeed({ refName: input.refName }),
2631 },2648 },
2632 vcsDriver: {2649 vcsStatusBroadcaster: {
2633 isInsideWorkTree: () => Effect.succeed(true),2650 refreshStatus: () =>
2651 Effect.succeed({
2652 isRepo: true,
2653 hasPrimaryRemote: true,
2654 isDefaultRef: true,
2655 refName: "main",
2656 hasWorkingTreeChanges: false,
2657 workingTree: { files: [], insertions: 0, deletions: 0 },
2658 hasUpstream: true,
2659 aheadCount: 0,
2660 behindCount: 0,
2661 pr: null,
2662 }),
2663 },
2664 reviewService: {
2665 getDiffPreview: (input) =>
2666 Effect.succeed({
2667 cwd: input.cwd,
2668 generatedAt: DateTime.nowUnsafe(),
2669 sources: [
2670 {
2671 id: "working-tree",
2672 kind: "working-tree",
2673 title: "Dirty worktree",
2674 baseRef: "HEAD",
2675 headRef: null,
2676 diff: "dirty-diff",
2677 diffHash: "hash-dirty",
2678 truncated: false,
2679 },
2680 {
2681 id: "branch-range",
2682 kind: "branch-range",
2683 title: "Against main",
2684 baseRef: "main",
2685 headRef: "feature/demo",
2686 diff: "base-diff",
2687 diffHash: "hash-base",
2688 truncated: false,
2689 },
2690 ],
2691 }),
2634 },2692 },
2635 },2693 },
2636 });2694 });
...@@ -2738,6 +2796,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2738,6 +2796,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2738 }),2796 }),
2739 ),2797 ),
2740 );2798 );
2799
2800 const diffPreview = yield* Effect.scoped(
2801 withWsRpcClient(wsUrl, (client) =>
2802 client[WS_METHODS.reviewGetDiffPreview]({ cwd: "/tmp/repo" }),
2803 ),
2804 );
2805 assert.equal(diffPreview.sources[0]?.diff, "dirty-diff");
2741 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2806 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2742 );2807 );
27432808
...@@ -4177,6 +4242,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4177,6 +4242,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4177 history: "",4242 history: "",
4178 exitCode: null,4243 exitCode: null,
4179 exitSignal: null,4244 exitSignal: null,
4245 label: "Primary",
4180 updatedAt: "2026-01-01T00:00:00.000Z",4246 updatedAt: "2026-01-01T00:00:00.000Z",
4181 };4247 };
41824248
apps/server/src/server.ts+7
...@@ -56,6 +56,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts";...@@ -56,6 +56,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts";
56import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";56import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
57import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";57import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
58import * as GitWorkflowService from "./git/GitWorkflowService.ts";58import * as GitWorkflowService from "./git/GitWorkflowService.ts";
59import * as ReviewService from "./review/ReviewService.ts";
59import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";60import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
60import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";61import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
61import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";62import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
...@@ -199,11 +200,17 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l...@@ -199,11 +200,17 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l
199 Layer.provideMerge(SourceControlProviderRegistryLayerLive),200 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
200);201);
201202
203const ReviewLayerLive = ReviewService.layer.pipe(
204 Layer.provideMerge(GitVcsDriver.layer),
205 Layer.provideMerge(VcsDriverRegistryLayerLive),
206);
207
202const VcsLayerLive = Layer.empty.pipe(208const VcsLayerLive = Layer.empty.pipe(
203 Layer.provideMerge(VcsProjectConfig.layer),209 Layer.provideMerge(VcsProjectConfig.layer),
204 Layer.provideMerge(VcsDriverRegistryLayerLive),210 Layer.provideMerge(VcsDriverRegistryLayerLive),
205 Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))),211 Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
206 Layer.provideMerge(GitWorkflowLayerLive),212 Layer.provideMerge(GitWorkflowLayerLive),
213 Layer.provideMerge(ReviewLayerLive),
207 Layer.provideMerge(SourceControlRepositoryServiceLayerLive),214 Layer.provideMerge(SourceControlRepositoryServiceLayerLive),
208 Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))),215 Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))),
209);216);
apps/server/src/terminal/Layers/Manager.test.ts+348-20
...@@ -2,7 +2,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -2,7 +2,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import {3import {
4 DEFAULT_TERMINAL_ID,4 DEFAULT_TERMINAL_ID,
5 type TerminalAttachStreamEvent,
5 type TerminalEvent,6 type TerminalEvent,
7 type TerminalMetadataStreamEvent,
6 type TerminalOpenInput,8 type TerminalOpenInput,
7 type TerminalRestartInput,9 type TerminalRestartInput,
8} from "@t3tools/contracts";10} from "@t3tools/contracts";
...@@ -199,7 +201,10 @@ interface CreateManagerOptions {...@@ -199,7 +201,10 @@ interface CreateManagerOptions {
199 shellResolver?: () => string;201 shellResolver?: () => string;
200 platform?: NodeJS.Platform;202 platform?: NodeJS.Platform;
201 env?: NodeJS.ProcessEnv;203 env?: NodeJS.ProcessEnv;
202 subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>;204 subprocessInspector?: (terminalPid: number) => Effect.Effect<{
205 readonly hasRunningSubprocess: boolean;
206 readonly childCommand: string | null;
207 }>;
203 subprocessPollIntervalMs?: number;208 subprocessPollIntervalMs?: number;
204 processKillGraceMs?: number;209 processKillGraceMs?: number;
205 maxRetainedInactiveSessions?: number;210 maxRetainedInactiveSessions?: number;
...@@ -236,8 +241,8 @@ const createManager = (...@@ -236,8 +241,8 @@ const createManager = (
236 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),241 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
237 ...(options.platform !== undefined ? { platform: options.platform } : {}),242 ...(options.platform !== undefined ? { platform: options.platform } : {}),
238 ...(options.env !== undefined ? { env: options.env } : {}),243 ...(options.env !== undefined ? { env: options.env } : {}),
239 ...(options.subprocessChecker !== undefined244 ...(options.subprocessInspector !== undefined
240 ? { subprocessChecker: options.subprocessChecker }245 ? { subprocessInspector: options.subprocessInspector }
241 : {}),246 : {}),
242 ...(options.subprocessPollIntervalMs !== undefined247 ...(options.subprocessPollIntervalMs !== undefined
243 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }248 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
...@@ -248,11 +253,10 @@ const createManager = (...@@ -248,11 +253,10 @@ const createManager = (
248 : {}),253 : {}),
249 });254 });
250 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);255 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
251 const scope = yield* Effect.scope;
252 const unsubscribe = yield* manager.subscribe((event) =>256 const unsubscribe = yield* manager.subscribe((event) =>
253 Ref.update(eventsRef, (events) => [...events, event]),257 Ref.update(eventsRef, (events) => [...events, event]),
254 );258 );
255 yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));259 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
256260
257 return {261 return {
258 baseDir,262 baseDir,
...@@ -281,13 +285,116 @@ it.layer(...@@ -281,13 +285,116 @@ it.layer(
281 const third = yield* manager.open(openInput());285 const third = yield* manager.open(openInput());
282286
283 assert.equal(first.threadId, "thread-1");287 assert.equal(first.threadId, "thread-1");
284 assert.equal(first.terminalId, "default");288 assert.equal(first.terminalId, DEFAULT_TERMINAL_ID);
285 assert.equal(second.threadId, "thread-1");289 assert.equal(second.threadId, "thread-1");
286 assert.equal(third.threadId, "thread-1");290 assert.equal(third.threadId, "thread-1");
287 expect(ptyAdapter.spawnInputs).toHaveLength(1);291 expect(ptyAdapter.spawnInputs).toHaveLength(1);
288 }),292 }),
289 );293 );
290294
295 it.effect("attaches to running sessions without restarting them", () =>
296 Effect.gen(function* () {
297 const { manager, ptyAdapter } = yield* createManager();
298
299 yield* manager.open(openInput());
300 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
301 const unsubscribe = yield* manager.attachStream(
302 {
303 threadId: "thread-1",
304 terminalId: DEFAULT_TERMINAL_ID,
305 cols: 100,
306 rows: 40,
307 },
308 (event) => Ref.update(attachEvents, (events) => [...events, event]),
309 );
310 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
311
312 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
313 expect(snapshot).toBeDefined();
314 if (!snapshot || snapshot.type !== "snapshot") return;
315 assert.equal(snapshot.snapshot.threadId, "thread-1");
316 assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID);
317 expect(ptyAdapter.spawnInputs).toHaveLength(1);
318 }),
319 );
320
321 it.effect("attaches to exited sessions without restarting them", () =>
322 Effect.gen(function* () {
323 const { manager, ptyAdapter, getEvents } = yield* createManager();
324
325 yield* manager.open(openInput());
326 const process = ptyAdapter.processes[0];
327 expect(process).toBeDefined();
328 if (!process) return;
329
330 process.emitExit({ exitCode: 0, signal: 0 });
331
332 yield* waitFor(
333 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
334 "1200 millis",
335 );
336
337 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
338 const unsubscribe = yield* manager.attachStream(
339 openInput({
340 env: {
341 T3CODE_WORKTREE_PATH: "/tmp/should-not-restart",
342 },
343 worktreePath: "/tmp/should-not-restart",
344 }),
345 (event) => Ref.update(attachEvents, (events) => [...events, event]),
346 );
347 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
348
349 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
350 expect(snapshot).toBeDefined();
351 if (!snapshot || snapshot.type !== "snapshot") return;
352 assert.equal(snapshot.snapshot.status, "exited");
353 assert.equal(snapshot.snapshot.worktreePath, null);
354 expect(ptyAdapter.spawnInputs).toHaveLength(1);
355 }),
356 );
357
358 it.effect("restarts inactive sessions from attach only when requested", () =>
359 Effect.gen(function* () {
360 const { manager, ptyAdapter, getEvents } = yield* createManager();
361
362 yield* manager.open(openInput());
363 const process = ptyAdapter.processes[0];
364 expect(process).toBeDefined();
365 if (!process) return;
366
367 process.emitExit({ exitCode: 0, signal: 0 });
368
369 yield* waitFor(
370 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
371 "1200 millis",
372 );
373
374 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
375 const unsubscribe = yield* manager.attachStream(
376 {
377 ...openInput({
378 env: {
379 T3CODE_WORKTREE_PATH: "/tmp/restart-requested",
380 },
381 worktreePath: "/tmp/restart-requested",
382 }),
383 restartIfNotRunning: true,
384 },
385 (event) => Ref.update(attachEvents, (events) => [...events, event]),
386 );
387 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
388
389 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
390 expect(snapshot).toBeDefined();
391 if (!snapshot || snapshot.type !== "snapshot") return;
392 assert.equal(snapshot.snapshot.status, "running");
393 assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested");
394 expect(ptyAdapter.spawnInputs).toHaveLength(2);
395 }),
396 );
397
291 const makeDirectory = (filePath: string) =>398 const makeDirectory = (filePath: string) =>
292 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>399 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
293 fs.makeDirectory(filePath, { recursive: true }),400 fs.makeDirectory(filePath, { recursive: true }),
...@@ -436,7 +543,7 @@ it.layer(...@@ -436,7 +543,7 @@ it.layer(
436 (event) =>543 (event) =>
437 event.type === "cleared" &&544 event.type === "cleared" &&
438 event.threadId === "thread-1" &&545 event.threadId === "thread-1" &&
439 event.terminalId === "default",546 event.terminalId === DEFAULT_TERMINAL_ID,
440 ),547 ),
441 ).toBe(true);548 ).toBe(true);
442 }),549 }),
...@@ -472,6 +579,34 @@ it.layer(...@@ -472,6 +579,34 @@ it.layer(
472 }),579 }),
473 );580 );
474581
582 it.effect("restarts a running session when open is called with a different cwd", () =>
583 Effect.gen(function* () {
584 const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager();
585 const path = yield* Path.Path;
586 const originalCwd = path.join(baseDir, "original");
587 const differentCwd = path.join(baseDir, "different");
588 yield* makeDirectory(originalCwd);
589 yield* makeDirectory(differentCwd);
590
591 yield* manager.open(openInput({ cwd: originalCwd }));
592 const firstProcess = ptyAdapter.processes[0];
593 expect(firstProcess).toBeDefined();
594 if (!firstProcess) return;
595
596 firstProcess.emitData("before reopen\n");
597 const logPath = yield* historyLogPath(logsDir);
598 yield* waitFor(pathExists(logPath));
599
600 const reopened = yield* manager.open(openInput({ cwd: differentCwd }));
601
602 expect(ptyAdapter.spawnInputs).toHaveLength(2);
603 assert.equal(firstProcess.killed, true);
604 assert.equal(reopened.cwd, differentCwd);
605 assert.equal(reopened.history, "");
606 yield* waitFor(Effect.map(readFileString(logPath), (text) => text === ""));
607 }),
608 );
609
475 it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () =>610 it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () =>
476 Effect.gen(function* () {611 Effect.gen(function* () {
477 const { manager, getEvents, baseDir } = yield* createManager();612 const { manager, getEvents, baseDir } = yield* createManager();
...@@ -607,27 +742,40 @@ it.layer(...@@ -607,27 +742,40 @@ it.layer(
607742
608 it.effect("emits subprocess activity events when child-process state changes", () =>743 it.effect("emits subprocess activity events when child-process state changes", () =>
609 Effect.gen(function* () {744 Effect.gen(function* () {
610 let hasRunningSubprocess = false;745 let inspect: {
746 readonly hasRunningSubprocess: boolean;
747 readonly childCommand: string | null;
748 } = { hasRunningSubprocess: false, childCommand: null };
611 const { manager, getEvents } = yield* createManager(5, {749 const { manager, getEvents } = yield* createManager(5, {
612 subprocessChecker: () => Effect.succeed(hasRunningSubprocess),750 subprocessInspector: () => Effect.succeed(inspect),
613 subprocessPollIntervalMs: 20,751 subprocessPollIntervalMs: 20,
614 });752 });
615753
616 yield* manager.open(openInput());754 yield* manager.open(openInput());
617 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);755 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);
618756
619 hasRunningSubprocess = true;757 inspect = { hasRunningSubprocess: true, childCommand: "vim" };
620 yield* waitFor(758 yield* waitFor(
621 Effect.map(getEvents, (events) =>759 Effect.map(getEvents, (events) =>
622 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true),760 events.some(
761 (event) =>
762 event.type === "activity" &&
763 event.hasRunningSubprocess === true &&
764 event.label === "vim",
765 ),
623 ),766 ),
624 "1200 millis",767 "1200 millis",
625 );768 );
626769
627 hasRunningSubprocess = false;770 inspect = { hasRunningSubprocess: false, childCommand: null };
628 yield* waitFor(771 yield* waitFor(
629 Effect.map(getEvents, (events) =>772 Effect.map(getEvents, (events) =>
630 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false),773 events.some(
774 (event) =>
775 event.type === "activity" &&
776 event.hasRunningSubprocess === false &&
777 event.label === "Terminal 1",
778 ),
631 ),779 ),
632 "1200 millis",780 "1200 millis",
633 );781 );
...@@ -638,9 +786,9 @@ it.layer(...@@ -638,9 +786,9 @@ it.layer(
638 Effect.gen(function* () {786 Effect.gen(function* () {
639 let checks = 0;787 let checks = 0;
640 const { manager } = yield* createManager(5, {788 const { manager } = yield* createManager(5, {
641 subprocessChecker: () => {789 subprocessInspector: () => {
642 checks += 1;790 checks += 1;
643 return Effect.succeed(false);791 return Effect.succeed({ hasRunningSubprocess: false, childCommand: null });
644 },792 },
645 subprocessPollIntervalMs: 20,793 subprocessPollIntervalMs: 20,
646 });794 });
...@@ -851,6 +999,21 @@ it.layer(...@@ -851,6 +999,21 @@ it.layer(
851 }).pipe(Effect.provide(TestClock.layer())),999 }).pipe(Effect.provide(TestClock.layer())),
852 );1000 );
8531001
1002 it.effect("publishes closed events when terminals are explicitly closed", () =>
1003 Effect.gen(function* () {
1004 const { manager, getEvents } = yield* createManager();
1005 yield* manager.open(openInput({ terminalId: "default" }));
1006 yield* manager.open(openInput({ terminalId: "sidecar" }));
1007
1008 yield* manager.close({ threadId: "thread-1" });
1009
1010 const closedEvents = (yield* getEvents).filter(
1011 (event): event is Extract<TerminalEvent, { type: "closed" }> => event.type === "closed",
1012 );
1013 expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]);
1014 }),
1015 );
1016
854 it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () =>1017 it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () =>
855 Effect.gen(function* () {1018 Effect.gen(function* () {
856 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, {1019 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, {
...@@ -1108,12 +1271,11 @@ it.layer(...@@ -1108,12 +1271,11 @@ it.layer(
1108 const { manager, ptyAdapter } = yield* createManager(5, {1271 const { manager, ptyAdapter } = yield* createManager(5, {
1109 ptyAdapter: new FakePtyAdapter("async"),1272 ptyAdapter: new FakePtyAdapter("async"),
1110 });1273 });
1111 const scope = yield* Effect.scope;
1112 const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);1274 const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
1113 const unsubscribe = yield* manager.subscribe((event) =>1275 const unsubscribe = yield* manager.subscribe((event) =>
1114 Ref.update(subscriberEvents, (events) => [...events, event]),1276 Ref.update(subscriberEvents, (events) => [...events, event]),
1115 );1277 );
1116 yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));1278 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
11171279
1118 yield* manager.open(openInput());1280 yield* manager.open(openInput());
1119 const process = ptyAdapter.processes[0];1281 const process = ptyAdapter.processes[0];
...@@ -1133,6 +1295,157 @@ it.layer(...@@ -1133,6 +1295,157 @@ it.layer(
1133 }),1295 }),
1134 );1296 );
11351297
1298 it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () =>
1299 Effect.gen(function* () {
1300 const { manager } = yield* createManager();
1301 yield* manager.open(openInput({ threadId: "existing-thread" }));
1302
1303 const metadataEvents = yield* Ref.make<ReadonlyArray<TerminalMetadataStreamEvent>>([]);
1304 const unsubscribe = yield* manager.subscribeMetadata((event) =>
1305 Ref.update(metadataEvents, (events) => [...events, event]),
1306 );
1307 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1308
1309 const initialEvents = yield* Ref.get(metadataEvents);
1310 expect(initialEvents[0]).toMatchObject({
1311 type: "snapshot",
1312 terminals: [
1313 {
1314 threadId: "existing-thread",
1315 terminalId: DEFAULT_TERMINAL_ID,
1316 },
1317 ],
1318 });
1319
1320 yield* manager.open(openInput({ threadId: "new-thread" }));
1321
1322 yield* waitFor(
1323 Effect.map(Ref.get(metadataEvents), (events) =>
1324 events.some(
1325 (event) =>
1326 event.type === "upsert" &&
1327 event.terminal.threadId === "new-thread" &&
1328 event.terminal.terminalId === DEFAULT_TERMINAL_ID,
1329 ),
1330 ),
1331 "1200 millis",
1332 );
1333
1334 yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID });
1335
1336 yield* waitFor(
1337 Effect.map(Ref.get(metadataEvents), (events) =>
1338 events.some(
1339 (event) =>
1340 event.type === "remove" &&
1341 event.threadId === "new-thread" &&
1342 event.terminalId === DEFAULT_TERMINAL_ID,
1343 ),
1344 ),
1345 "1200 millis",
1346 );
1347 }),
1348 );
1349
1350 it.effect("removes terminal metadata subscriptions when initial delivery fails", () =>
1351 Effect.gen(function* () {
1352 const { manager } = yield* createManager();
1353 yield* manager.open(openInput({ threadId: "existing-thread" }));
1354
1355 const leakedLiveEvents = yield* Ref.make(0);
1356 const exit = yield* Effect.exit(
1357 manager.subscribeMetadata((event) =>
1358 event.type === "snapshot"
1359 ? Effect.die("snapshot listener failed")
1360 : Ref.update(leakedLiveEvents, (count) => count + 1),
1361 ),
1362 );
1363
1364 expect(Exit.isFailure(exit)).toBe(true);
1365
1366 yield* manager.open(openInput({ threadId: "new-thread" }));
1367 expect(yield* Ref.get(leakedLiveEvents)).toBe(0);
1368 }),
1369 );
1370
1371 it.effect(
1372 "streams attach snapshots followed by live events without duplicate start snapshots",
1373 () =>
1374 Effect.gen(function* () {
1375 const { manager, ptyAdapter } = yield* createManager(5, {
1376 ptyAdapter: new FakePtyAdapter("async"),
1377 });
1378 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1379 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
1380 Ref.update(attachEvents, (events) => [...events, event]),
1381 );
1382 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1383
1384 const process = ptyAdapter.processes[0];
1385 expect(process).toBeDefined();
1386 if (!process) return;
1387
1388 expect(yield* Ref.get(attachEvents)).toMatchObject([
1389 {
1390 type: "snapshot",
1391 snapshot: {
1392 threadId: "thread-1",
1393 terminalId: DEFAULT_TERMINAL_ID,
1394 },
1395 },
1396 ]);
1397
1398 process.emitData("hello from attach\n");
1399
1400 yield* waitFor(
1401 Effect.map(Ref.get(attachEvents), (events) =>
1402 events.some((event) => event.type === "output" && event.data === "hello from attach\n"),
1403 ),
1404 "1200 millis",
1405 );
1406
1407 const events = yield* Ref.get(attachEvents);
1408 expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1);
1409 }),
1410 );
1411
1412 it.effect("buffers attach output delivered during the initial snapshot callback", () =>
1413 Effect.gen(function* () {
1414 const { manager, ptyAdapter } = yield* createManager(5, {
1415 ptyAdapter: new FakePtyAdapter("async"),
1416 });
1417 yield* manager.open(openInput());
1418
1419 const process = ptyAdapter.processes[0];
1420 expect(process).toBeDefined();
1421 if (!process) return;
1422
1423 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1424 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
1425 Effect.gen(function* () {
1426 yield* Ref.update(attachEvents, (events) => [...events, event]);
1427 if (event.type === "snapshot") {
1428 yield* Effect.sync(() => process.emitData("during snapshot\n"));
1429 yield* Effect.yieldNow;
1430 }
1431 }),
1432 );
1433 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1434
1435 yield* waitFor(
1436 Effect.map(Ref.get(attachEvents), (events) =>
1437 events.some((event) => event.type === "output" && event.data === "during snapshot\n"),
1438 ),
1439 "1200 millis",
1440 );
1441
1442 expect(yield* Ref.get(attachEvents)).toMatchObject([
1443 { type: "snapshot" },
1444 { type: "output", data: "during snapshot\n" },
1445 ]);
1446 }),
1447 );
1448
1136 it.effect("preserves queued PTY output ordering through exit callbacks", () =>1449 it.effect("preserves queued PTY output ordering through exit callbacks", () =>
1137 Effect.gen(function* () {1450 Effect.gen(function* () {
1138 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {1451 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
...@@ -1162,10 +1475,25 @@ it.layer(...@@ -1162,10 +1475,25 @@ it.layer(
1162 (event) => event.type === "output" || event.type === "exited",1475 (event) => event.type === "output" || event.type === "exited",
1163 );1476 );
1164 expect(relevant).toEqual([1477 expect(relevant).toEqual([
1165 expect.objectContaining({ type: "output", data: "first\n" }),1478 expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }),
1166 expect.objectContaining({ type: "output", data: "second\n" }),1479 expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }),
1167 expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0 }),1480 expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }),
1168 ]);1481 ]);
1482
1483 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1484 const unsubscribe = yield* manager.attachStream(
1485 {
1486 threadId: "thread-1",
1487 terminalId: DEFAULT_TERMINAL_ID,
1488 },
1489 (event) => Ref.update(attachEvents, (events) => [...events, event]),
1490 );
1491 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1492
1493 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
1494 expect(snapshot).toBeDefined();
1495 if (!snapshot || snapshot.type !== "snapshot") return;
1496 expect(snapshot.snapshot.sequence).toBe(4);
1169 }),1497 }),
1170 );1498 );
11711499
apps/server/src/terminal/Layers/Manager.ts+619-192
...@@ -1,10 +1,16 @@...@@ -1,10 +1,16 @@
1import {1import {
2 DEFAULT_TERMINAL_ID,2 DEFAULT_TERMINAL_ID,
3 type TerminalAttachInput,
4 type TerminalAttachStreamEvent,
3 type TerminalEvent,5 type TerminalEvent,
6 type TerminalMetadataStreamEvent,
7 type TerminalOpenInput,
4 type TerminalSessionSnapshot,8 type TerminalSessionSnapshot,
5 type TerminalSessionStatus,9 type TerminalSessionStatus,
10 type TerminalSummary,
6} from "@t3tools/contracts";11} from "@t3tools/contracts";
7import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";12import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
13import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
8import * as DateTime from "effect/DateTime";14import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
10import * as Encoding from "effect/Encoding";16import * as Encoding from "effect/Encoding";
...@@ -52,6 +58,7 @@ const DEFAULT_OPEN_COLS = 120;...@@ -52,6 +58,7 @@ const DEFAULT_OPEN_COLS = 120;
52const DEFAULT_OPEN_ROWS = 30;58const DEFAULT_OPEN_ROWS = 30;
53const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);59const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
54const nowIso = Effect.map(DateTime.now, DateTime.formatIso);60const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
61const MAX_TERMINAL_LABEL_LENGTH = 128;
5562
56class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(63class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
57 "TerminalSubprocessCheckError",64 "TerminalSubprocessCheckError",
...@@ -72,10 +79,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess...@@ -72,10 +79,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess
72 },79 },
73) {}80) {}
7481
75interface TerminalSubprocessChecker {82interface TerminalSubprocessInspectResult {
83 readonly hasRunningSubprocess: boolean;
84 readonly childCommand: string | null;
85}
86
87interface TerminalSubprocessInspector {
76 (88 (
77 terminalPid: number,89 terminalPid: number,
78 ): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner>;90 ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;
79}91}
8092
81interface ShellCandidate {93interface ShellCandidate {
...@@ -108,12 +120,15 @@ interface TerminalSessionState {...@@ -108,12 +120,15 @@ interface TerminalSessionState {
108 exitCode: number | null;120 exitCode: number | null;
109 exitSignal: number | null;121 exitSignal: number | null;
110 updatedAt: string;122 updatedAt: string;
123 eventSequence: number;
111 cols: number;124 cols: number;
112 rows: number;125 rows: number;
113 process: PtyProcess | null;126 process: PtyProcess | null;
114 unsubscribeData: (() => void) | null;127 unsubscribeData: (() => void) | null;
115 unsubscribeExit: (() => void) | null;128 unsubscribeExit: (() => void) | null;
116 hasRunningSubprocess: boolean;129 hasRunningSubprocess: boolean;
130 /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */
131 childCommandLabel: string | null;
117 runtimeEnv: Record<string, string> | null;132 runtimeEnv: Record<string, string> | null;
118}133}
119134
...@@ -130,6 +145,7 @@ type DrainProcessEventAction =...@@ -130,6 +145,7 @@ type DrainProcessEventAction =
130 type: "output";145 type: "output";
131 threadId: string;146 threadId: string;
132 terminalId: string;147 terminalId: string;
148 sequence: number;
133 history: string | null;149 history: string | null;
134 data: string;150 data: string;
135 }151 }
...@@ -138,6 +154,7 @@ type DrainProcessEventAction =...@@ -138,6 +154,7 @@ type DrainProcessEventAction =
138 process: PtyProcess | null;154 process: PtyProcess | null;
139 threadId: string;155 threadId: string;
140 terminalId: string;156 terminalId: string;
157 sequence: number;
141 exitCode: number | null;158 exitCode: number | null;
142 exitSignal: number | null;159 exitSignal: number | null;
143 };160 };
...@@ -147,6 +164,39 @@ interface TerminalManagerState {...@@ -147,6 +164,39 @@ interface TerminalManagerState {
147 killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;164 killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;
148}165}
149166
167function truncateTerminalWireLabel(value: string): string {
168 if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value;
169 return value.slice(0, MAX_TERMINAL_LABEL_LENGTH);
170}
171
172function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null {
173 let trimmed = raw.trim();
174 if (trimmed.length === 0) return null;
175 if (
176 (trimmed.startsWith("[") && trimmed.endsWith("]")) ||
177 (trimmed.startsWith("(") && trimmed.endsWith(")"))
178 ) {
179 trimmed = trimmed.slice(1, -1).trim();
180 }
181 const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim();
182 if (firstToken.length === 0) return null;
183 const separators = platform === "win32" ? /[\\/]/ : /\//;
184 const base = firstToken.split(separators).at(-1) ?? firstToken;
185 const withoutExe =
186 platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base;
187 return withoutExe.length > 0 ? withoutExe : null;
188}
189
190function terminalWireLabel(session: TerminalSessionState): string {
191 if (session.hasRunningSubprocess && session.childCommandLabel) {
192 const trimmed = session.childCommandLabel.trim();
193 if (trimmed.length > 0) {
194 return truncateTerminalWireLabel(trimmed);
195 }
196 }
197 return truncateTerminalWireLabel(getTerminalLabel(session.terminalId));
198}
199
150function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {200function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
151 return {201 return {
152 threadId: session.threadId,202 threadId: session.threadId,
...@@ -158,10 +208,83 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {...@@ -158,10 +208,83 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
158 history: session.history,208 history: session.history,
159 exitCode: session.exitCode,209 exitCode: session.exitCode,
160 exitSignal: session.exitSignal,210 exitSignal: session.exitSignal,
211 label: terminalWireLabel(session),
161 updatedAt: session.updatedAt,212 updatedAt: session.updatedAt,
213 sequence: session.eventSequence,
162 };214 };
163}215}
164216
217function summary(session: TerminalSessionState): TerminalSummary {
218 return {
219 threadId: session.threadId,
220 terminalId: session.terminalId,
221 cwd: session.cwd,
222 worktreePath: session.worktreePath,
223 status: session.status,
224 pid: session.pid,
225 exitCode: session.exitCode,
226 exitSignal: session.exitSignal,
227 hasRunningSubprocess: session.hasRunningSubprocess,
228 label: terminalWireLabel(session),
229 updatedAt: session.updatedAt,
230 };
231}
232
233function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean {
234 switch (event.type) {
235 case "started":
236 case "restarted":
237 case "exited":
238 case "closed":
239 case "error":
240 case "activity":
241 return true;
242 case "output":
243 case "cleared":
244 return false;
245 }
246}
247
248function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null {
249 switch (event.type) {
250 case "started":
251 return {
252 type: "snapshot",
253 snapshot: event.snapshot,
254 };
255 case "output":
256 case "exited":
257 case "closed":
258 case "error":
259 case "cleared":
260 case "restarted":
261 case "activity":
262 return event;
263 }
264}
265
266function isDuplicateAttachSnapshotEvent(
267 event: TerminalEvent,
268 initialSnapshot: TerminalSessionSnapshot,
269) {
270 return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number"
271 ? event.sequence <= initialSnapshot.sequence
272 : event.type === "started" &&
273 event.snapshot.threadId === initialSnapshot.threadId &&
274 event.snapshot.terminalId === initialSnapshot.terminalId &&
275 event.snapshot.updatedAt <= initialSnapshot.updatedAt;
276}
277
278function advanceEventSequence(session: TerminalSessionState): {
279 readonly updatedAt: string;
280 readonly sequence: number;
281} {
282 const updatedAt = DateTime.formatIso(DateTime.nowUnsafe());
283 session.eventSequence += 1;
284 session.updatedAt = updatedAt;
285 return { updatedAt, sequence: session.eventSequence };
286}
287
165function cleanupProcessHandles(session: TerminalSessionState): void {288function cleanupProcessHandles(session: TerminalSessionState): void {
166 session.unsubscribeData?.();289 session.unsubscribeData?.();
167 session.unsubscribeData = null;290 session.unsubscribeData = null;
...@@ -364,13 +487,29 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean {...@@ -364,13 +487,29 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean {
364 );487 );
365}488}
366489
367function checkWindowsSubprocessActivity(490function parseFirstChildPidFromPgrep(stdout: string): number | null {
491 for (const line of stdout.split(/\r?\n/g)) {
492 const n = Number.parseInt(line.trim(), 10);
493 if (Number.isInteger(n) && n > 0) {
494 return n;
495 }
496 }
497 return null;
498}
499
500function windowsInspectSubprocess(
368 terminalPid: number,501 terminalPid: number,
369): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {502 platform: NodeJS.Platform,
503): Effect.Effect<
504 TerminalSubprocessInspectResult,
505 TerminalSubprocessCheckError,
506 ProcessRunner.ProcessRunner
507> {
370 const command = [508 const command = [
371 `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,509 `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`,
372 "if ($children) { exit 0 }",510 "if ($null -eq $c) { exit 1 }",
373 "exit 1",511 "Write-Output $c.Name",
512 "exit 0",
374 ].join("; ");513 ].join("; ");
375 return Effect.gen(function* () {514 return Effect.gen(function* () {
376 const processRunner = yield* ProcessRunner.ProcessRunner;515 const processRunner = yield* ProcessRunner.ProcessRunner;
...@@ -384,11 +523,24 @@ function checkWindowsSubprocessActivity(...@@ -384,11 +523,24 @@ function checkWindowsSubprocessActivity(
384 timeoutBehavior: "timedOutResult",523 timeoutBehavior: "timedOutResult",
385 });524 });
386 }).pipe(525 }).pipe(
387 Effect.map((result) => result.code === 0),526 Effect.map((result) => {
527 if (result.code !== 0) {
528 return { hasRunningSubprocess: false, childCommand: null } as const;
529 }
530 const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? "";
531 if (name.length === 0) {
532 return { hasRunningSubprocess: true, childCommand: null } as const;
533 }
534 const normalized = normalizeChildCommandName(name, platform);
535 return {
536 hasRunningSubprocess: true,
537 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
538 } as const;
539 }),
388 Effect.mapError(540 Effect.mapError(
389 (cause) =>541 (cause) =>
390 new TerminalSubprocessCheckError({542 new TerminalSubprocessCheckError({
391 message: "Failed to check Windows terminal subprocess activity.",543 message: "Failed to inspect Windows terminal subprocesses.",
392 cause,544 cause,
393 terminalPid,545 terminalPid,
394 command: "powershell",546 command: "powershell",
...@@ -397,9 +549,14 @@ function checkWindowsSubprocessActivity(...@@ -397,9 +549,14 @@ function checkWindowsSubprocessActivity(
397 );549 );
398}550}
399551
400const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (552const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* (
401 terminalPid: number,553 terminalPid: number,
402): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {554 platform: NodeJS.Platform,
555): Effect.fn.Return<
556 TerminalSubprocessInspectResult,
557 TerminalSubprocessCheckError,
558 ProcessRunner.ProcessRunner
559> {
403 const processRunner = yield* ProcessRunner.ProcessRunner;560 const processRunner = yield* ProcessRunner.ProcessRunner;
404 const runPgrep = processRunner561 const runPgrep = processRunner
405 .run({562 .run({
...@@ -443,45 +600,88 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct...@@ -443,45 +600,88 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct
443 ),600 ),
444 );601 );
445602
603 let childPid: number | null = null;
604
446 const pgrepResult = yield* Effect.exit(runPgrep);605 const pgrepResult = yield* Effect.exit(runPgrep);
447 if (pgrepResult._tag === "Success") {606 if (pgrepResult._tag === "Success") {
448 if (pgrepResult.value.code === 0) {607 if (pgrepResult.value.code === 0) {
449 return pgrepResult.value.stdout.trim().length > 0;608 childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
450 }609 } else if (pgrepResult.value.code === 1) {
451 if (pgrepResult.value.code === 1) {610 return { hasRunningSubprocess: false, childCommand: null };
452 return false;
453 }611 }
454 }612 }
455613
456 const psResult = yield* Effect.exit(runPs);614 if (childPid === null) {
457 if (psResult._tag === "Failure" || psResult.value.code !== 0) {615 const psResult = yield* Effect.exit(runPs);
458 return false;616 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
617 return { hasRunningSubprocess: false, childCommand: null };
618 }
619 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
620 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
621 const pid = Number(pidRaw);
622 const ppid = Number(ppidRaw);
623 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
624 if (ppid === terminalPid) {
625 childPid = pid;
626 break;
627 }
628 }
459 }629 }
460630
461 for (const line of psResult.value.stdout.split(/\r?\n/g)) {631 if (childPid === null) {
462 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);632 return { hasRunningSubprocess: false, childCommand: null };
463 const pid = Number(pidRaw);
464 const ppid = Number(ppidRaw);
465 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
466 if (ppid === terminalPid) {
467 return true;
468 }
469 }633 }
470 return false;
471});
472634
473const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* (635 const runComm = processRunner.run({
474 terminalPid: number,636 command: "ps",
475): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {637 args: ["-p", String(childPid), "-o", "comm="],
476 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {638 timeout: "1 second",
477 return false;639 maxOutputBytes: 8_192,
640 outputMode: "truncate",
641 timeoutBehavior: "timedOutResult",
642 });
643
644 const commResult = yield* Effect.exit(runComm);
645 let rawComm: string | null = null;
646 if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) {
647 rawComm = commResult.value.stdout.trim();
478 }648 }
479 if (process.platform === "win32") {649
480 return yield* checkWindowsSubprocessActivity(terminalPid);650 if (!rawComm || rawComm.length === 0) {
651 const runArgs = processRunner.run({
652 command: "ps",
653 args: ["-p", String(childPid), "-o", "args="],
654 timeout: "1 second",
655 maxOutputBytes: 16_384,
656 outputMode: "truncate",
657 timeoutBehavior: "timedOutResult",
658 });
659 const argsResult = yield* Effect.exit(runArgs);
660 if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) {
661 const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? "";
662 rawComm = first.length > 0 ? first : null;
663 }
481 }664 }
482 return yield* checkPosixSubprocessActivity(terminalPid);665
666 const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;
667 return {
668 hasRunningSubprocess: true,
669 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
670 };
483});671});
484672
673function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) {
674 return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {
675 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
676 return { hasRunningSubprocess: false, childCommand: null };
677 }
678 if (platform === "win32") {
679 return yield* windowsInspectSubprocess(terminalPid, platform);
680 }
681 return yield* posixInspectSubprocess(terminalPid, platform);
682 });
683}
684
485function capHistory(history: string, maxLines: number): string {685function capHistory(history: string, maxLines: number): string {
486 if (history.length === 0) return history;686 if (history.length === 0) return history;
487 const hasTrailingNewline = history.endsWith("\n");687 const hasTrailingNewline = history.endsWith("\n");
...@@ -729,7 +929,7 @@ interface TerminalManagerOptions {...@@ -729,7 +929,7 @@ interface TerminalManagerOptions {
729 shellResolver?: () => string;929 shellResolver?: () => string;
730 platform?: NodeJS.Platform;930 platform?: NodeJS.Platform;
731 env?: NodeJS.ProcessEnv;931 env?: NodeJS.ProcessEnv;
732 subprocessChecker?: TerminalSubprocessChecker;932 subprocessInspector?: TerminalSubprocessInspector;
733 subprocessPollIntervalMs?: number;933 subprocessPollIntervalMs?: number;
734 processKillGraceMs?: number;934 processKillGraceMs?: number;
735 maxRetainedInactiveSessions?: number;935 maxRetainedInactiveSessions?: number;
...@@ -757,10 +957,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -757,10 +957,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
757 const baseEnv = options.env ?? process.env;957 const baseEnv = options.env ?? process.env;
758 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));958 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
759 const processRunner = yield* ProcessRunner.ProcessRunner;959 const processRunner = yield* ProcessRunner.ProcessRunner;
760 const subprocessChecker =960 const subprocessInspector =
761 options.subprocessChecker ??961 options.subprocessInspector ??
762 ((terminalPid) =>962 ((terminalPid) =>
763 defaultSubprocessChecker(terminalPid).pipe(963 defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe(
764 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),964 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
765 ));965 ));
766 const subprocessPollIntervalMs =966 const subprocessPollIntervalMs =
...@@ -1200,7 +1400,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1200,7 +1400,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1200 expectedPid: number,1400 expectedPid: number,
1201 ) {1401 ) {
1202 while (true) {1402 while (true) {
1203 const updatedAt = yield* nowIso;
1204 const action: DrainProcessEventAction = yield* Effect.sync(() => {1403 const action: DrainProcessEventAction = yield* Effect.sync(() => {
1205 if (session.pid !== expectedPid || !session.process || session.status !== "running") {1404 if (session.pid !== expectedPid || !session.process || session.status !== "running") {
1206 session.pendingProcessEvents = [];1405 session.pendingProcessEvents = [];
...@@ -1235,12 +1434,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1235,12 +1434,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1235 historyLineLimit,1434 historyLineLimit,
1236 );1435 );
1237 }1436 }
1238 session.updatedAt = updatedAt;1437 const eventStamp = advanceEventSequence(session);
12391438
1240 return {1439 return {
1241 type: "output",1440 type: "output",
1242 threadId: session.threadId,1441 threadId: session.threadId,
1243 terminalId: session.terminalId,1442 terminalId: session.terminalId,
1443 sequence: eventStamp.sequence,
1244 history: sanitized.visibleText.length > 0 ? session.history : null,1444 history: sanitized.visibleText.length > 0 ? session.history : null,
1245 data: nextEvent.data,1445 data: nextEvent.data,
1246 } as const;1446 } as const;
...@@ -1251,6 +1451,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1251,6 +1451,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1251 session.process = null;1451 session.process = null;
1252 session.pid = null;1452 session.pid = null;
1253 session.hasRunningSubprocess = false;1453 session.hasRunningSubprocess = false;
1454 session.childCommandLabel = null;
1254 session.status = "exited";1455 session.status = "exited";
1255 session.pendingHistoryControlSequence = "";1456 session.pendingHistoryControlSequence = "";
1256 session.pendingProcessEvents = [];1457 session.pendingProcessEvents = [];
...@@ -1262,13 +1463,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1262,13 +1463,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1262 session.exitSignal = Number.isInteger(nextEvent.event.signal)1463 session.exitSignal = Number.isInteger(nextEvent.event.signal)
1263 ? nextEvent.event.signal1464 ? nextEvent.event.signal
1264 : null;1465 : null;
1265 session.updatedAt = updatedAt;1466 const eventStamp = advanceEventSequence(session);
12661467
1267 return {1468 return {
1268 type: "exit",1469 type: "exit",
1269 process,1470 process,
1270 threadId: session.threadId,1471 threadId: session.threadId,
1271 terminalId: session.terminalId,1472 terminalId: session.terminalId,
1473 sequence: eventStamp.sequence,
1272 exitCode: session.exitCode,1474 exitCode: session.exitCode,
1273 exitSignal: session.exitSignal,1475 exitSignal: session.exitSignal,
1274 } as const;1476 } as const;
...@@ -1283,24 +1485,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1283,24 +1485,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1283 yield* queuePersist(action.threadId, action.terminalId, action.history);1485 yield* queuePersist(action.threadId, action.terminalId, action.history);
1284 }1486 }
12851487
1286 const createdAt = yield* nowIso;
1287 yield* publishEvent({1488 yield* publishEvent({
1288 type: "output",1489 type: "output",
1289 threadId: action.threadId,1490 threadId: action.threadId,
1290 terminalId: action.terminalId,1491 terminalId: action.terminalId,
1291 createdAt,1492 sequence: action.sequence,
1292 data: action.data,1493 data: action.data,
1293 });1494 });
1294 continue;1495 continue;
1295 }1496 }
12961497
1297 yield* clearKillFiber(action.process);1498 yield* clearKillFiber(action.process);
1298 const createdAt = yield* nowIso;
1299 yield* publishEvent({1499 yield* publishEvent({
1300 type: "exited",1500 type: "exited",
1301 threadId: action.threadId,1501 threadId: action.threadId,
1302 terminalId: action.terminalId,1502 terminalId: action.terminalId,
1303 createdAt,1503 sequence: action.sequence,
1304 exitCode: action.exitCode,1504 exitCode: action.exitCode,
1305 exitSignal: action.exitSignal,1505 exitSignal: action.exitSignal,
1306 });1506 });
...@@ -1321,6 +1521,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1321,6 +1521,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1321 session.process = null;1521 session.process = null;
1322 session.pid = null;1522 session.pid = null;
1323 session.hasRunningSubprocess = false;1523 session.hasRunningSubprocess = false;
1524 session.childCommandLabel = null;
1324 session.status = "exited";1525 session.status = "exited";
1325 session.pendingHistoryControlSequence = "";1526 session.pendingHistoryControlSequence = "";
1326 session.pendingProcessEvents = [];1527 session.pendingProcessEvents = [];
...@@ -1415,6 +1616,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1415,6 +1616,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1415 session.exitCode = null;1616 session.exitCode = null;
1416 session.exitSignal = null;1617 session.exitSignal = null;
1417 session.hasRunningSubprocess = false;1618 session.hasRunningSubprocess = false;
1619 session.childCommandLabel = null;
1418 session.pendingProcessEvents = [];1620 session.pendingProcessEvents = [];
1419 session.pendingProcessEventIndex = 0;1621 session.pendingProcessEventIndex = 0;
1420 session.processEventDrainRunning = false;1622 session.processEventDrainRunning = false;
...@@ -1449,23 +1651,25 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1449,23 +1651,25 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1449 runFork(drainProcessEvents(session, processPid));1651 runFork(drainProcessEvents(session, processPid));
1450 });1652 });
14511653
1452 const runningAt = yield* nowIso;1654 let eventStamp: ReturnType<typeof advanceEventSequence> = {
1655 updatedAt: session.updatedAt,
1656 sequence: session.eventSequence,
1657 };
1453 yield* modifyManagerState((state) => {1658 yield* modifyManagerState((state) => {
1454 session.process = ptyProcess;1659 session.process = ptyProcess;
1455 session.pid = processPid;1660 session.pid = processPid;
1456 session.status = "running";1661 session.status = "running";
1457 session.updatedAt = runningAt;
1458 session.unsubscribeData = unsubscribeData;1662 session.unsubscribeData = unsubscribeData;
1459 session.unsubscribeExit = unsubscribeExit;1663 session.unsubscribeExit = unsubscribeExit;
1664 eventStamp = advanceEventSequence(session);
1460 return [undefined, state] as const;1665 return [undefined, state] as const;
1461 });1666 });
14621667
1463 const createdAt = yield* nowIso;
1464 yield* publishEvent({1668 yield* publishEvent({
1465 type: eventType,1669 type: eventType,
1466 threadId: session.threadId,1670 threadId: session.threadId,
1467 terminalId: session.terminalId,1671 terminalId: session.terminalId,
1468 createdAt,1672 sequence: eventStamp.sequence,
1469 snapshot: snapshot(session),1673 snapshot: snapshot(session),
1470 });1674 });
1471 }),1675 }),
...@@ -1482,7 +1686,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1482,7 +1686,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1482 if (ptyProcess) {1686 if (ptyProcess) {
1483 yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId);1687 yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId);
1484 }1688 }
1485 const erroredAt = yield* nowIso;
14861689
1487 yield* modifyManagerState((state) => {1690 yield* modifyManagerState((state) => {
1488 session.status = "error";1691 session.status = "error";
...@@ -1491,22 +1694,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1491,22 +1694,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1491 session.unsubscribeData = null;1694 session.unsubscribeData = null;
1492 session.unsubscribeExit = null;1695 session.unsubscribeExit = null;
1493 session.hasRunningSubprocess = false;1696 session.hasRunningSubprocess = false;
1697 session.childCommandLabel = null;
1494 session.pendingProcessEvents = [];1698 session.pendingProcessEvents = [];
1495 session.pendingProcessEventIndex = 0;1699 session.pendingProcessEventIndex = 0;
1496 session.processEventDrainRunning = false;1700 session.processEventDrainRunning = false;
1497 session.updatedAt = erroredAt;1701 advanceEventSequence(session);
1498 return [undefined, state] as const;1702 return [undefined, state] as const;
1499 });1703 });
15001704
1501 yield* evictInactiveSessionsIfNeeded();1705 yield* evictInactiveSessionsIfNeeded();
15021706
1503 const message = error.message;1707 const message = error.message;
1504 const createdAt = yield* nowIso;
1505 yield* publishEvent({1708 yield* publishEvent({
1506 type: "error",1709 type: "error",
1507 threadId: session.threadId,1710 threadId: session.threadId,
1508 terminalId: session.terminalId,1711 terminalId: session.terminalId,
1509 createdAt,1712 sequence: session.eventSequence,
1510 message,1713 message,
1511 });1714 });
1512 yield* Effect.logError("failed to start terminal", {1715 yield* Effect.logError("failed to start terminal", {
...@@ -1525,6 +1728,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1525,6 +1728,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1525 ) {1728 ) {
1526 const key = toSessionKey(threadId, terminalId);1729 const key = toSessionKey(threadId, terminalId);
1527 const session = yield* getSession(threadId, terminalId);1730 const session = yield* getSession(threadId, terminalId);
1731 const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0;
15281732
1529 if (Option.isSome(session)) {1733 if (Option.isSome(session)) {
1530 yield* stopProcess(session.value);1734 yield* stopProcess(session.value);
...@@ -1533,15 +1737,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1533,15 +1737,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
15331737
1534 yield* flushPersist(threadId, terminalId);1738 yield* flushPersist(threadId, terminalId);
15351739
1536 yield* modifyManagerState((state) => {1740 const removed = yield* modifyManagerState((state) => {
1537 if (!state.sessions.has(key)) {1741 if (!state.sessions.has(key)) {
1538 return [undefined, state] as const;1742 return [false, state] as const;
1539 }1743 }
1540 const sessions = new Map(state.sessions);1744 const sessions = new Map(state.sessions);
1541 sessions.delete(key);1745 sessions.delete(key);
1542 return [undefined, { ...state, sessions }] as const;1746 return [true, { ...state, sessions }] as const;
1543 });1747 });
15441748
1749 if (removed) {
1750 yield* publishEvent({
1751 type: "closed",
1752 threadId,
1753 terminalId,
1754 sequence: closedEventSequence,
1755 });
1756 }
1757
1545 if (deleteHistoryOnClose) {1758 if (deleteHistoryOnClose) {
1546 yield* deleteHistory(threadId, terminalId);1759 yield* deleteHistory(threadId, terminalId);
1547 }1760 }
...@@ -1562,7 +1775,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1562,7 +1775,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1562 session: TerminalSessionState & { pid: number },1775 session: TerminalSessionState & { pid: number },
1563 ) {1776 ) {
1564 const terminalPid = session.pid;1777 const terminalPid = session.pid;
1565 const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe(1778 const inspectResult = yield* subprocessInspector(terminalPid).pipe(
1566 Effect.map(Option.some),1779 Effect.map(Option.some),
1567 Effect.catch((reason) =>1780 Effect.catch((reason) =>
1568 Effect.logWarning("failed to check terminal subprocess activity", {1781 Effect.logWarning("failed to check terminal subprocess activity", {
...@@ -1570,16 +1783,16 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1570,16 +1783,16 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1570 terminalId: session.terminalId,1783 terminalId: session.terminalId,
1571 terminalPid,1784 terminalPid,
1572 reason,1785 reason,
1573 }).pipe(Effect.as(Option.none<boolean>())),1786 }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())),
1574 ),1787 ),
1575 );1788 );
15761789
1577 if (Option.isNone(hasRunningSubprocess)) {1790 if (Option.isNone(inspectResult)) {
1578 return;1791 return;
1579 }1792 }
15801793
1581 const updatedAt = yield* nowIso;1794 const next = inspectResult.value;
1582 const createdAt = yield* nowIso;1795 const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;
1583 const event = yield* modifyManagerState((state) => {1796 const event = yield* modifyManagerState((state) => {
1584 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(1797 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
1585 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),1798 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),
...@@ -1588,21 +1801,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1588,21 +1801,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1588 Option.isNone(liveSession) ||1801 Option.isNone(liveSession) ||
1589 liveSession.value.status !== "running" ||1802 liveSession.value.status !== "running" ||
1590 liveSession.value.pid !== terminalPid ||1803 liveSession.value.pid !== terminalPid ||
1591 liveSession.value.hasRunningSubprocess === hasRunningSubprocess.value1804 (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess &&
1805 liveSession.value.childCommandLabel === nextChildLabel)
1592 ) {1806 ) {
1593 return [Option.none(), state] as const;1807 return [Option.none(), state] as const;
1594 }1808 }
15951809
1596 liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value;1810 liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess;
1597 liveSession.value.updatedAt = updatedAt;1811 liveSession.value.childCommandLabel = nextChildLabel;
1812 const eventStamp = advanceEventSequence(liveSession.value);
15981813
1599 return [1814 return [
1600 Option.some({1815 Option.some({
1601 type: "activity" as const,1816 type: "activity" as const,
1602 threadId: liveSession.value.threadId,1817 threadId: liveSession.value.threadId,
1603 terminalId: liveSession.value.terminalId,1818 terminalId: liveSession.value.terminalId,
1604 createdAt,1819 sequence: eventStamp.sequence,
1605 hasRunningSubprocess: hasRunningSubprocess.value,1820 hasRunningSubprocess: next.hasRunningSubprocess,
1821 label: terminalWireLabel(liveSession.value),
1606 }),1822 }),
1607 state,1823 state,
1608 ] as const;1824 ] as const;
...@@ -1666,136 +1882,352 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1666,136 +1882,352 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1666 }).pipe(Effect.ignoreCause({ log: true })),1882 }).pipe(Effect.ignoreCause({ log: true })),
1667 );1883 );
16681884
1885 const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) {
1886 const terminalId = input.terminalId;
1887 yield* assertValidCwd(input.cwd);
1888
1889 const sessionKey = toSessionKey(input.threadId, terminalId);
1890 const existing = yield* getSession(input.threadId, terminalId);
1891 if (Option.isNone(existing)) {
1892 yield* flushPersist(input.threadId, terminalId);
1893 const history = yield* readHistory(input.threadId, terminalId);
1894 const cols = input.cols ?? DEFAULT_OPEN_COLS;
1895 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
1896 const session: TerminalSessionState = {
1897 threadId: input.threadId,
1898 terminalId,
1899 cwd: input.cwd,
1900 worktreePath: input.worktreePath ?? null,
1901 status: "starting",
1902 pid: null,
1903 history,
1904 pendingHistoryControlSequence: "",
1905 pendingProcessEvents: [],
1906 pendingProcessEventIndex: 0,
1907 processEventDrainRunning: false,
1908 exitCode: null,
1909 exitSignal: null,
1910 updatedAt: yield* nowIso,
1911 eventSequence: 0,
1912 cols,
1913 rows,
1914 process: null,
1915 unsubscribeData: null,
1916 unsubscribeExit: null,
1917 hasRunningSubprocess: false,
1918 childCommandLabel: null,
1919 runtimeEnv: normalizedRuntimeEnv(input.env),
1920 };
1921
1922 const createdSession = session;
1923 yield* modifyManagerState((state) => {
1924 const sessions = new Map(state.sessions);
1925 sessions.set(sessionKey, createdSession);
1926 return [undefined, { ...state, sessions }] as const;
1927 });
1928
1929 yield* evictInactiveSessionsIfNeeded();
1930 yield* startSession(
1931 session,
1932 {
1933 threadId: input.threadId,
1934 terminalId,
1935 cwd: input.cwd,
1936 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
1937 cols,
1938 rows,
1939 ...(input.env ? { env: input.env } : {}),
1940 },
1941 "started",
1942 );
1943 return snapshot(session);
1944 }
1945
1946 const liveSession = existing.value;
1947 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
1948 const currentRuntimeEnv = liveSession.runtimeEnv;
1949 const targetCols = input.cols ?? liveSession.cols;
1950 const targetRows = input.rows ?? liveSession.rows;
1951 const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv);
1952 const nextWorktreePath =
1953 input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath;
1954 const launchContextChanged =
1955 liveSession.cwd !== input.cwd ||
1956 runtimeEnvChanged ||
1957 liveSession.worktreePath !== nextWorktreePath;
1958
1959 if (launchContextChanged) {
1960 yield* stopProcess(liveSession);
1961 liveSession.cwd = input.cwd;
1962 liveSession.worktreePath = nextWorktreePath;
1963 liveSession.runtimeEnv = nextRuntimeEnv;
1964 liveSession.history = "";
1965 liveSession.pendingHistoryControlSequence = "";
1966 liveSession.pendingProcessEvents = [];
1967 liveSession.pendingProcessEventIndex = 0;
1968 liveSession.processEventDrainRunning = false;
1969 yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history);
1970 } else if (liveSession.status === "exited" || liveSession.status === "error") {
1971 liveSession.runtimeEnv = nextRuntimeEnv;
1972 liveSession.worktreePath = nextWorktreePath;
1973 liveSession.history = "";
1974 liveSession.pendingHistoryControlSequence = "";
1975 liveSession.pendingProcessEvents = [];
1976 liveSession.pendingProcessEventIndex = 0;
1977 liveSession.processEventDrainRunning = false;
1978 yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history);
1979 }
1980
1981 if (!liveSession.process) {
1982 yield* startSession(
1983 liveSession,
1984 {
1985 threadId: input.threadId,
1986 terminalId,
1987 cwd: input.cwd,
1988 worktreePath: liveSession.worktreePath,
1989 cols: targetCols,
1990 rows: targetRows,
1991 ...(input.env ? { env: input.env } : {}),
1992 },
1993 "started",
1994 );
1995 return snapshot(liveSession);
1996 }
1997
1998 if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) {
1999 liveSession.cols = targetCols;
2000 liveSession.rows = targetRows;
2001 liveSession.updatedAt = yield* nowIso;
2002 liveSession.process.resize(targetCols, targetRows);
2003 }
2004
2005 return snapshot(liveSession);
2006 });
2007
1669 const open: TerminalManagerShape["open"] = (input) =>2008 const open: TerminalManagerShape["open"] = (input) =>
2009 withThreadLock(input.threadId, openLocked(input));
2010
2011 const openOrAttachForStream = (input: TerminalAttachInput) =>
1670 withThreadLock(2012 withThreadLock(
1671 input.threadId,2013 input.threadId,
1672 Effect.gen(function* () {2014 Effect.gen(function* () {
1673 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2015 const terminalId = input.terminalId;
1674 yield* assertValidCwd(input.cwd);
1675
1676 const sessionKey = toSessionKey(input.threadId, terminalId);
1677 const existing = yield* getSession(input.threadId, terminalId);2016 const existing = yield* getSession(input.threadId, terminalId);
2017
1678 if (Option.isNone(existing)) {2018 if (Option.isNone(existing)) {
1679 yield* flushPersist(input.threadId, terminalId);2019 if (!input.cwd) {
1680 const history = yield* readHistory(input.threadId, terminalId);2020 return yield* new TerminalSessionLookupError({
1681 const cols = input.cols ?? DEFAULT_OPEN_COLS;2021 threadId: input.threadId,
1682 const rows = input.rows ?? DEFAULT_OPEN_ROWS;2022 terminalId,
1683 const createdAt = yield* nowIso;2023 });
1684 const session: TerminalSessionState = {2024 }
1685 threadId: input.threadId,2025
2026 return yield* openLocked({
2027 ...input,
1686 terminalId,2028 terminalId,
1687 cwd: input.cwd,2029 cwd: input.cwd,
1688 worktreePath: input.worktreePath ?? null,2030 });
1689 status: "starting",2031 }
1690 pid: null,
1691 history,
1692 pendingHistoryControlSequence: "",
1693 pendingProcessEvents: [],
1694 pendingProcessEventIndex: 0,
1695 processEventDrainRunning: false,
1696 exitCode: null,
1697 exitSignal: null,
1698 updatedAt: createdAt,
1699 cols,
1700 rows,
1701 process: null,
1702 unsubscribeData: null,
1703 unsubscribeExit: null,
1704 hasRunningSubprocess: false,
1705 runtimeEnv: normalizedRuntimeEnv(input.env),
1706 };
17072032
1708 const createdSession = session;2033 const session = existing.value;
1709 yield* modifyManagerState((state) => {2034 const targetCols = input.cols ?? session.cols;
1710 const sessions = new Map(state.sessions);2035 const targetRows = input.rows ?? session.rows;
1711 sessions.set(sessionKey, createdSession);2036
1712 return [undefined, { ...state, sessions }] as const;2037 if (!session.process && input.cwd && input.restartIfNotRunning === true) {
2038 return yield* openLocked({
2039 ...input,
2040 terminalId,
2041 cwd: input.cwd,
1713 });2042 });
2043 }
17142044
1715 yield* evictInactiveSessionsIfNeeded();2045 if (
1716 yield* startSession(2046 session.process &&
1717 session,2047 session.status === "running" &&
1718 {2048 (session.cols !== targetCols || session.rows !== targetRows)
1719 threadId: input.threadId,2049 ) {
1720 terminalId,2050 session.cols = targetCols;
1721 cwd: input.cwd,2051 session.rows = targetRows;
1722 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),2052 session.updatedAt = yield* nowIso;
1723 cols,2053 yield* Effect.sync(() => session.process?.resize(targetCols, targetRows));
1724 rows,
1725 ...(input.env ? { env: input.env } : {}),
1726 },
1727 "started",
1728 );
1729 return snapshot(session);
1730 }2054 }
17312055
1732 const liveSession = existing.value;2056 return snapshot(session);
1733 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);2057 }),
1734 const currentRuntimeEnv = liveSession.runtimeEnv;2058 );
1735 const targetCols = input.cols ?? liveSession.cols;2059
1736 const targetRows = input.rows ?? liveSession.rows;2060 const readAllTerminalMetadata = () =>
1737 const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv);2061 readManagerState.pipe(
17382062 Effect.map((state) =>
1739 if (liveSession.cwd !== input.cwd || runtimeEnvChanged) {2063 [...state.sessions.values()]
1740 yield* stopProcess(liveSession);2064 .map(summary)
1741 liveSession.cwd = input.cwd;2065 .sort(
1742 liveSession.worktreePath = input.worktreePath ?? null;2066 (left, right) =>
1743 liveSession.runtimeEnv = nextRuntimeEnv;2067 right.updatedAt.localeCompare(left.updatedAt) ||
1744 liveSession.history = "";2068 left.threadId.localeCompare(right.threadId) ||
1745 liveSession.pendingHistoryControlSequence = "";2069 left.terminalId.localeCompare(right.terminalId),
1746 liveSession.pendingProcessEvents = [];2070 ),
1747 liveSession.pendingProcessEventIndex = 0;2071 ),
1748 liveSession.processEventDrainRunning = false;2072 );
1749 yield* persistHistory(2073
1750 liveSession.threadId,2074 const readTerminalMetadata = (input: {
1751 liveSession.terminalId,2075 readonly threadId: string;
1752 liveSession.history,2076 readonly terminalId: string;
1753 );2077 }) =>
1754 } else if (liveSession.status === "exited" || liveSession.status === "error") {2078 getSession(input.threadId, input.terminalId).pipe(
1755 liveSession.runtimeEnv = nextRuntimeEnv;2079 Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)),
1756 liveSession.worktreePath = input.worktreePath ?? null;2080 );
1757 liveSession.history = "";2081
1758 liveSession.pendingHistoryControlSequence = "";2082 const subscribe: TerminalManagerShape["subscribe"] = (listener) =>
1759 liveSession.pendingProcessEvents = [];2083 Effect.sync(() => {
1760 liveSession.pendingProcessEventIndex = 0;2084 terminalEventListeners.add(listener);
1761 liveSession.processEventDrainRunning = false;2085 return () => {
1762 yield* persistHistory(2086 terminalEventListeners.delete(listener);
1763 liveSession.threadId,2087 };
1764 liveSession.terminalId,2088 });
1765 liveSession.history,2089
1766 );2090 const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => {
2091 let unsubscribe: (() => void) | null = null;
2092
2093 return Effect.gen(function* () {
2094 const bufferedEvents: TerminalEvent[] = [];
2095 let deliverLive = false;
2096
2097 unsubscribe = yield* subscribe((event) => {
2098 if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
2099 return Effect.void;
1767 }2100 }
17682101
1769 if (!liveSession.process) {2102 if (!deliverLive) {
1770 yield* startSession(2103 bufferedEvents.push(event);
1771 liveSession,2104 return Effect.void;
1772 {2105 }
1773 threadId: input.threadId,2106
1774 terminalId,2107 const attachEvent = terminalEventToAttachEvent(event);
1775 cwd: input.cwd,2108 return attachEvent ? listener(attachEvent) : Effect.void;
1776 worktreePath: liveSession.worktreePath,2109 });
1777 cols: targetCols,2110
1778 rows: targetRows,2111 const initialSnapshot = yield* openOrAttachForStream(input);
1779 ...(input.env ? { env: input.env } : {}),2112
1780 },2113 yield* listener({
1781 "started",2114 type: "snapshot",
1782 );2115 snapshot: initialSnapshot,
1783 return snapshot(liveSession);2116 });
2117
2118 for (const event of bufferedEvents) {
2119 if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) {
2120 continue;
1784 }2121 }
17852122
1786 if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) {2123 const attachEvent = terminalEventToAttachEvent(event);
1787 liveSession.cols = targetCols;2124 if (attachEvent) {
1788 liveSession.rows = targetRows;2125 yield* listener(attachEvent);
1789 liveSession.updatedAt = yield* nowIso;
1790 liveSession.process.resize(targetCols, targetRows);
1791 }2126 }
2127 }
17922128
1793 return snapshot(liveSession);2129 deliverLive = true;
1794 }),2130 return () => {
2131 unsubscribe?.();
2132 unsubscribe = null;
2133 };
2134 }).pipe(
2135 Effect.catchCause((cause) =>
2136 Effect.flatMap(
2137 Effect.sync(() => {
2138 unsubscribe?.();
2139 unsubscribe = null;
2140 }),
2141 () => Effect.failCause(cause),
2142 ),
2143 ),
2144 );
2145 };
2146
2147 const metadataEventFromTerminalEvent = (
2148 event: TerminalEvent,
2149 ): Effect.Effect<TerminalMetadataStreamEvent | null> => {
2150 if (!shouldPublishTerminalMetadataEvent(event)) {
2151 return Effect.succeed(null);
2152 }
2153
2154 if (event.type === "closed") {
2155 return Effect.succeed({
2156 type: "remove" as const,
2157 threadId: event.threadId,
2158 terminalId: event.terminalId,
2159 });
2160 }
2161
2162 return readTerminalMetadata({
2163 threadId: event.threadId,
2164 terminalId: event.terminalId,
2165 }).pipe(
2166 Effect.map((terminal) =>
2167 terminal
2168 ? {
2169 type: "upsert" as const,
2170 terminal,
2171 }
2172 : null,
2173 ),
2174 );
2175 };
2176
2177 const offerMetadataEvent = (
2178 listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,
2179 event: TerminalEvent,
2180 ) =>
2181 metadataEventFromTerminalEvent(event).pipe(
2182 Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)),
2183 );
2184
2185 const subscribeMetadata: TerminalManagerShape["subscribeMetadata"] = (listener) => {
2186 let unsubscribe: (() => void) | null = null;
2187
2188 return Effect.gen(function* () {
2189 const bufferedEvents: TerminalEvent[] = [];
2190 let deliverLive = false;
2191
2192 unsubscribe = yield* subscribe((event) => {
2193 if (!deliverLive) {
2194 bufferedEvents.push(event);
2195 return Effect.void;
2196 }
2197
2198 return offerMetadataEvent(listener, event);
2199 });
2200
2201 const terminals = yield* readAllTerminalMetadata();
2202 yield* listener({
2203 type: "snapshot",
2204 terminals,
2205 });
2206
2207 for (const event of bufferedEvents) {
2208 yield* offerMetadataEvent(listener, event);
2209 }
2210
2211 deliverLive = true;
2212 return () => {
2213 unsubscribe?.();
2214 unsubscribe = null;
2215 };
2216 }).pipe(
2217 Effect.catchCause((cause) =>
2218 Effect.flatMap(
2219 Effect.sync(() => {
2220 unsubscribe?.();
2221 unsubscribe = null;
2222 }),
2223 () => Effect.failCause(cause),
2224 ),
2225 ),
1795 );2226 );
2227 };
17962228
1797 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {2229 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {
1798 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2230 const terminalId = input.terminalId;
1799 const session = yield* requireSession(input.threadId, terminalId);2231 const session = yield* requireSession(input.threadId, terminalId);
1800 const process = session.process;2232 const process = session.process;
1801 if (!process || session.status !== "running") {2233 if (!process || session.status !== "running") {
...@@ -1809,7 +2241,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1809,7 +2241,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1809 });2241 });
18102242
1811 const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) {2243 const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) {
1812 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2244 const terminalId = input.terminalId;
1813 const session = yield* requireSession(input.threadId, terminalId);2245 const session = yield* requireSession(input.threadId, terminalId);
1814 const process = session.process;2246 const process = session.process;
1815 if (!process || session.status !== "running") {2247 if (!process || session.status !== "running") {
...@@ -1828,22 +2260,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1828,22 +2260,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1828 withThreadLock(2260 withThreadLock(
1829 input.threadId,2261 input.threadId,
1830 Effect.gen(function* () {2262 Effect.gen(function* () {
1831 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2263 const terminalId = input.terminalId;
1832 const session = yield* requireSession(input.threadId, terminalId);2264 const session = yield* requireSession(input.threadId, terminalId);
1833 const updatedAt = yield* nowIso;
1834 session.history = "";2265 session.history = "";
1835 session.pendingHistoryControlSequence = "";2266 session.pendingHistoryControlSequence = "";
1836 session.pendingProcessEvents = [];2267 session.pendingProcessEvents = [];
1837 session.pendingProcessEventIndex = 0;2268 session.pendingProcessEventIndex = 0;
1838 session.processEventDrainRunning = false;2269 session.processEventDrainRunning = false;
1839 session.updatedAt = updatedAt;2270 const eventStamp = advanceEventSequence(session);
1840 yield* persistHistory(input.threadId, terminalId, session.history);2271 yield* persistHistory(input.threadId, terminalId, session.history);
1841 const createdAt = yield* nowIso;
1842 yield* publishEvent({2272 yield* publishEvent({
1843 type: "cleared",2273 type: "cleared",
1844 threadId: input.threadId,2274 threadId: input.threadId,
1845 terminalId,2275 terminalId,
1846 createdAt,2276 sequence: eventStamp.sequence,
1847 });2277 });
1848 }),2278 }),
1849 );2279 );
...@@ -1853,7 +2283,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1853,7 +2283,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1853 input.threadId,2283 input.threadId,
1854 Effect.gen(function* () {2284 Effect.gen(function* () {
1855 yield* increment(terminalRestartsTotal, { scope: "thread" });2285 yield* increment(terminalRestartsTotal, { scope: "thread" });
1856 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2286 const terminalId = input.terminalId;
1857 yield* assertValidCwd(input.cwd);2287 yield* assertValidCwd(input.cwd);
18582288
1859 const sessionKey = toSessionKey(input.threadId, terminalId);2289 const sessionKey = toSessionKey(input.threadId, terminalId);
...@@ -1862,7 +2292,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1862,7 +2292,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1862 if (Option.isNone(existingSession)) {2292 if (Option.isNone(existingSession)) {
1863 const cols = input.cols ?? DEFAULT_OPEN_COLS;2293 const cols = input.cols ?? DEFAULT_OPEN_COLS;
1864 const rows = input.rows ?? DEFAULT_OPEN_ROWS;2294 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
1865 const createdAt = yield* nowIso;
1866 session = {2295 session = {
1867 threadId: input.threadId,2296 threadId: input.threadId,
1868 terminalId,2297 terminalId,
...@@ -1877,13 +2306,15 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1877,13 +2306,15 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1877 processEventDrainRunning: false,2306 processEventDrainRunning: false,
1878 exitCode: null,2307 exitCode: null,
1879 exitSignal: null,2308 exitSignal: null,
1880 updatedAt: createdAt,2309 updatedAt: yield* nowIso,
2310 eventSequence: 0,
1881 cols,2311 cols,
1882 rows,2312 rows,
1883 process: null,2313 process: null,
1884 unsubscribeData: null,2314 unsubscribeData: null,
1885 unsubscribeExit: null,2315 unsubscribeExit: null,
1886 hasRunningSubprocess: false,2316 hasRunningSubprocess: false,
2317 childCommandLabel: null,
1887 runtimeEnv: normalizedRuntimeEnv(input.env),2318 runtimeEnv: normalizedRuntimeEnv(input.env),
1888 };2319 };
1889 const createdSession = session;2320 const createdSession = session;
...@@ -1951,18 +2382,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1951,18 +2382,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
19512382
1952 return {2383 return {
1953 open,2384 open,
2385 attachStream,
1954 write,2386 write,
1955 resize,2387 resize,
1956 clear,2388 clear,
1957 restart,2389 restart,
1958 close,2390 close,
1959 subscribe: (listener) =>2391 subscribe,
1960 Effect.sync(() => {2392 subscribeMetadata,
1961 terminalEventListeners.add(listener);
1962 return () => {
1963 terminalEventListeners.delete(listener);
1964 };
1965 }),
1966 } satisfies TerminalManagerShape;2393 } satisfies TerminalManagerShape;
1967 },2394 },
1968);2395);
apps/server/src/terminal/Services/Manager.ts+22
...@@ -7,12 +7,15 @@...@@ -7,12 +7,15 @@
7 * @module TerminalManager7 * @module TerminalManager
8 */8 */
9import {9import {
10 TerminalAttachInput,
11 TerminalAttachStreamEvent,
10 TerminalClearInput,12 TerminalClearInput,
11 TerminalCloseInput,13 TerminalCloseInput,
12 TerminalEvent,14 TerminalEvent,
13 TerminalCwdError,15 TerminalCwdError,
14 TerminalError,16 TerminalError,
15 TerminalHistoryError,17 TerminalHistoryError,
18 TerminalMetadataStreamEvent,
16 TerminalNotRunningError,19 TerminalNotRunningError,
17 TerminalOpenInput,20 TerminalOpenInput,
18 TerminalResizeInput,21 TerminalResizeInput,
...@@ -79,6 +82,16 @@ export interface TerminalManagerShape {...@@ -79,6 +82,16 @@ export interface TerminalManagerShape {
79 input: TerminalOpenInput,82 input: TerminalOpenInput,
80 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;83 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
8184
85 /**
86 * Attach to a terminal and stream its initial snapshot followed by live events.
87 *
88 * Returns an unsubscribe function.
89 */
90 readonly attachStream: (
91 input: TerminalAttachInput,
92 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
93 ) => Effect.Effect<() => void, TerminalError>;
94
82 /**95 /**
83 * Write input bytes to a terminal session.96 * Write input bytes to a terminal session.
84 */97 */
...@@ -118,6 +131,15 @@ export interface TerminalManagerShape {...@@ -118,6 +131,15 @@ export interface TerminalManagerShape {
118 readonly subscribe: (131 readonly subscribe: (
119 listener: (event: TerminalEvent) => Effect.Effect<void>,132 listener: (event: TerminalEvent) => Effect.Effect<void>,
120 ) => Effect.Effect<() => void>;133 ) => Effect.Effect<() => void>;
134
135 /**
136 * Subscribe to lightweight terminal metadata with an initial full snapshot.
137 *
138 * Returns an unsubscribe function.
139 */
140 readonly subscribeMetadata: (
141 listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,
142 ) => Effect.Effect<() => void>;
121}143}
122144
123/**145/**
apps/server/src/vcs/GitVcsDriver.ts+5
...@@ -18,6 +18,8 @@ import {...@@ -18,6 +18,8 @@ import {
18 type VcsCreateRefResult,18 type VcsCreateRefResult,
19 type VcsCreateWorktreeInput,19 type VcsCreateWorktreeInput,
20 type VcsCreateWorktreeResult,20 type VcsCreateWorktreeResult,
21 type ReviewDiffPreviewInput,
22 type ReviewDiffPreviewResult,
21 type VcsInitInput,23 type VcsInitInput,
22 type VcsListRefsInput,24 type VcsListRefsInput,
23 type VcsListRefsResult,25 type VcsListRefsResult,
...@@ -179,6 +181,9 @@ export interface GitVcsDriverShape {...@@ -179,6 +181,9 @@ export interface GitVcsDriverShape {
179 cwd: string,181 cwd: string,
180 baseRef: string,182 baseRef: string,
181 ) => Effect.Effect<GitRangeContext, GitCommandError>;183 ) => Effect.Effect<GitRangeContext, GitCommandError>;
184 readonly getReviewDiffPreview: (
185 input: ReviewDiffPreviewInput,
186 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;
182 readonly readConfigValue: (187 readonly readConfigValue: (
183 cwd: string,188 cwd: string,
184 key: string,189 key: string,
apps/server/src/vcs/GitVcsDriverCore.test.ts+25
...@@ -9,6 +9,7 @@ import * as Scope from "effect/Scope";...@@ -9,6 +9,7 @@ import * as Scope from "effect/Scope";
99
10import { GitCommandError } from "@t3tools/contracts";10import { GitCommandError } from "@t3tools/contracts";
11import { ServerConfig } from "../config.ts";11import { ServerConfig } from "../config.ts";
12import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts";
12import * as GitVcsDriver from "./GitVcsDriver.ts";13import * as GitVcsDriver from "./GitVcsDriver.ts";
1314
14const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {15const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
...@@ -77,6 +78,30 @@ const initRepoWithCommit = (...@@ -77,6 +78,30 @@ const initRepoWithCommit = (
77 });78 });
7879
79it.layer(TestLayer)("GitVcsDriver core integration", (it) => {80it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
81 describe("review diff previews", () => {
82 it.effect("drops an unterminated path from truncated NUL-separated git output", () =>
83 Effect.sync(() => {
84 const paths = splitNullSeparatedGitStdoutPaths({
85 stdout: "complete.txt\0partial",
86 stdoutTruncated: true,
87 });
88
89 assert.deepStrictEqual(paths, ["complete.txt"]);
90 }),
91 );
92
93 it.effect("keeps the final path when NUL-separated git output is complete", () =>
94 Effect.sync(() => {
95 const paths = splitNullSeparatedGitStdoutPaths({
96 stdout: "complete.txt\0final.txt",
97 stdoutTruncated: false,
98 });
99
100 assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]);
101 }),
102 );
103 });
104
80 describe("repository status", () => {105 describe("repository status", () => {
81 it.effect("reports non-repository directories without failing", () =>106 it.effect("reports non-repository directories without failing", () =>
82 Effect.gen(function* () {107 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriverCore.ts+189-1
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import * as Arr from "effect/Array";1import * as Arr from "effect/Array";
2import * as Cache from "effect/Cache";2import * as Cache from "effect/Cache";
3import * as Data from "effect/Data";3import * as Data from "effect/Data";
4import * as Crypto from "effect/Crypto";
4import * as DateTime from "effect/DateTime";5import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Encoding from "effect/Encoding";
7import * as Exit from "effect/Exit";9import * as Exit from "effect/Exit";
8import * as FileSystem from "effect/FileSystem";10import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";11import * as Option from "effect/Option";
...@@ -17,7 +19,12 @@ import * as Semaphore from "effect/Semaphore";...@@ -17,7 +19,12 @@ import * as Semaphore from "effect/Semaphore";
17import * as Stream from "effect/Stream";19import * as Stream from "effect/Stream";
18import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1921
20import { GitCommandError, type VcsRef } from "@t3tools/contracts";22import {
23 GitCommandError,
24 type ReviewDiffPreviewInput,
25 type ReviewDiffPreviewSource,
26 type VcsRef,
27} from "@t3tools/contracts";
21import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";28import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
22import { compactTraceAttributes } from "@t3tools/shared/observability";29import { compactTraceAttributes } from "@t3tools/shared/observability";
23import { decodeJsonResult } from "@t3tools/shared/schemaJson";30import { decodeJsonResult } from "@t3tools/shared/schemaJson";
...@@ -38,8 +45,12 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;...@@ -38,8 +45,12 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;
38const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;45const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
39const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;46const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
40const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;47const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
48const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;
49const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;
50const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;
41const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);51const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
42const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);52const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
53
43const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);54const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
44const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;55const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
45const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({56const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
...@@ -186,6 +197,23 @@ function paginateBranches(input: {...@@ -186,6 +197,23 @@ function paginateBranches(input: {
186 };197 };
187}198}
188199
200function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
201 const parts = input.split("\0");
202 if (parts.length === 0) return [];
203
204 if (truncated && parts[parts.length - 1]?.length) {
205 parts.pop();
206 }
207
208 return parts.filter((value) => value.length > 0);
209}
210
211export function splitNullSeparatedGitStdoutPaths(
212 result: Pick<GitVcsDriver.ExecuteGitResult, "stdout" | "stdoutTruncated">,
213): string[] {
214 return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated);
215}
216
189function sanitizeRemoteName(value: string): string {217function sanitizeRemoteName(value: string): string {
190 const sanitized = value218 const sanitized = value
191 .trim()219 .trim()
...@@ -611,6 +639,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -611,6 +639,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
611 const path = yield* Path.Path;639 const path = yield* Path.Path;
612 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;640 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
613 const { worktreesDir } = yield* ServerConfig;641 const { worktreesDir } = yield* ServerConfig;
642 const crypto = yield* Crypto.Crypto;
614643
615 const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced(644 const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced(
616 function* (input) {645 function* (input) {
...@@ -1631,6 +1660,164 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1631,6 +1660,164 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1631 };1660 };
1632 });1661 });
16331662
1663 const readUntrackedReviewDiffs = Effect.fn("readUntrackedReviewDiffs")(function* (cwd: string) {
1664 const untrackedResult = yield* executeGit(
1665 "GitVcsDriver.readUntrackedReviewDiffs.list",
1666 cwd,
1667 ["ls-files", "--others", "--exclude-standard", "-z"],
1668 {
1669 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
1670 appendTruncationMarker: true,
1671 },
1672 );
1673 const untrackedPaths = splitNullSeparatedGitStdoutPaths(untrackedResult);
1674 if (untrackedPaths.length === 0) {
1675 return { diff: "", truncated: untrackedResult.stdoutTruncated };
1676 }
1677
1678 const diffs = yield* Effect.forEach(
1679 untrackedPaths,
1680 (relativePath) =>
1681 executeGit(
1682 "GitVcsDriver.readUntrackedReviewDiffs.diff",
1683 cwd,
1684 ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", relativePath],
1685 {
1686 allowNonZeroExit: true,
1687 maxOutputBytes: REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES,
1688 appendTruncationMarker: true,
1689 },
1690 ),
1691 { concurrency: 4 },
1692 );
1693
1694 return {
1695 diff: Arr.filterMap(diffs, (result) =>
1696 result.stdout.trim().length > 0 ? Result.succeed(result.stdout) : Result.failVoid,
1697 ).join("\n"),
1698 truncated: untrackedResult.stdoutTruncated || diffs.some((result) => result.stdoutTruncated),
1699 };
1700 });
1701
1702 const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* (
1703 input: ReviewDiffPreviewInput,
1704 ) {
1705 const details = yield* statusDetailsLocal(input.cwd);
1706 if (!details.isRepo) {
1707 return {
1708 cwd: input.cwd,
1709 generatedAt: yield* DateTime.now,
1710 sources: [],
1711 };
1712 }
1713
1714 const branch = details.branch;
1715 const baseRef =
1716 input.baseRef ??
1717 (branch
1718 ? yield* resolveBaseBranchForNoUpstream(input.cwd, branch).pipe(
1719 Effect.catch(() => Effect.succeed(null)),
1720 )
1721 : null);
1722
1723 const dirtyTrackedResult = yield* executeGit(
1724 "GitVcsDriver.getReviewDiffPreview.dirtyTracked",
1725 input.cwd,
1726 ["diff", "--patch", "--minimal", "HEAD", "--"],
1727 {
1728 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,
1729 appendTruncationMarker: true,
1730 },
1731 ).pipe(
1732 Effect.catch(() =>
1733 Effect.succeed({
1734 exitCode: 0,
1735 stdout: "",
1736 stderr: "",
1737 stdoutTruncated: false,
1738 stderrTruncated: false,
1739 }),
1740 ),
1741 );
1742 const dirtyUntracked = yield* readUntrackedReviewDiffs(input.cwd).pipe(
1743 Effect.catch(() => Effect.succeed({ diff: "", truncated: false })),
1744 );
1745 const dirtyDiff = [dirtyTrackedResult.stdout.trimEnd(), dirtyUntracked.diff.trimEnd()]
1746 .filter((diff) => diff.length > 0)
1747 .join("\n");
1748
1749 const baseResult =
1750 baseRef && branch
1751 ? yield* executeGit(
1752 "GitVcsDriver.getReviewDiffPreview.base",
1753 input.cwd,
1754 ["diff", "--patch", "--minimal", `${baseRef}...HEAD`],
1755 {
1756 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,
1757 appendTruncationMarker: true,
1758 },
1759 ).pipe(
1760 Effect.catch(() =>
1761 Effect.succeed({
1762 exitCode: 0,
1763 stdout: "",
1764 stderr: "",
1765 stdoutTruncated: false,
1766 stderrTruncated: false,
1767 }),
1768 ),
1769 )
1770 : null;
1771 const baseDiff = baseResult?.stdout ?? "";
1772 const hashDiff = (diff: string) =>
1773 crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe(
1774 Effect.map(Encoding.encodeHex),
1775 Effect.mapError(
1776 toGitCommandError(
1777 {
1778 operation: "GitVcsDriver.getReviewDiffPreview.hash",
1779 cwd: input.cwd,
1780 args: [],
1781 },
1782 "failed to hash review diff.",
1783 ),
1784 ),
1785 );
1786 const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([
1787 hashDiff(dirtyDiff),
1788 hashDiff(baseDiff),
1789 ]);
1790
1791 const sources: ReviewDiffPreviewSource[] = [
1792 {
1793 id: "working-tree",
1794 kind: "working-tree",
1795 title: "Dirty worktree",
1796 baseRef: "HEAD",
1797 headRef: null,
1798 diff: dirtyDiff,
1799 diffHash: dirtyDiffHash,
1800 truncated: dirtyTrackedResult.stdoutTruncated || dirtyUntracked.truncated,
1801 },
1802 {
1803 id: "branch-range",
1804 kind: "branch-range",
1805 title: baseRef ? `Against ${baseRef}` : "Against base branch",
1806 baseRef,
1807 headRef: branch ?? "HEAD",
1808 diff: baseDiff,
1809 diffHash: baseDiffHash,
1810 truncated: baseResult?.stdoutTruncated ?? false,
1811 },
1812 ];
1813
1814 return {
1815 cwd: input.cwd,
1816 generatedAt: yield* DateTime.now,
1817 sources,
1818 };
1819 });
1820
1634 const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) =>1821 const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) =>
1635 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(1822 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(
1636 Effect.map((stdout) => stdout.trim()),1823 Effect.map((stdout) => stdout.trim()),
...@@ -2128,6 +2315,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2128,6 +2315,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2128 pushCurrentBranch,2315 pushCurrentBranch,
2129 pullCurrentBranch,2316 pullCurrentBranch,
2130 readRangeContext,2317 readRangeContext,
2318 getReviewDiffPreview,
2131 readConfigValue,2319 readConfigValue,
2132 listRefs,2320 listRefs,
2133 createWorktree,2321 createWorktree,
apps/server/src/vcs/VcsDriver.ts+5
...@@ -7,6 +7,8 @@ import type {...@@ -7,6 +7,8 @@ import type {
7 VcsInitInput,7 VcsInitInput,
8 VcsListRemotesResult,8 VcsListRemotesResult,
9 VcsListWorkspaceFilesResult,9 VcsListWorkspaceFilesResult,
10 ReviewDiffPreviewInput,
11 ReviewDiffPreviewResult,
10 VcsRepositoryIdentity,12 VcsRepositoryIdentity,
11} from "@t3tools/contracts";13} from "@t3tools/contracts";
12import { CheckpointRef } from "@t3tools/contracts";14import { CheckpointRef } from "@t3tools/contracts";
...@@ -67,6 +69,9 @@ export interface VcsDriverShape {...@@ -67,6 +69,9 @@ export interface VcsDriverShape {
67 relativePaths: ReadonlyArray<string>,69 relativePaths: ReadonlyArray<string>,
68 ) => Effect.Effect<ReadonlyArray<string>, VcsError>;70 ) => Effect.Effect<ReadonlyArray<string>, VcsError>;
69 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;71 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
72 readonly getDiffPreview?: (
73 input: ReviewDiffPreviewInput,
74 ) => Effect.Effect<ReviewDiffPreviewResult, VcsError>;
70}75}
7176
72export class VcsDriver extends Context.Service<VcsDriver, VcsDriverShape>()("t3/vcs/VcsDriver") {}77export class VcsDriver extends Context.Service<VcsDriver, VcsDriverShape>()("t3/vcs/VcsDriver") {}
apps/server/src/ws.ts+31
...@@ -30,7 +30,10 @@ import {...@@ -30,7 +30,10 @@ import {
30 OrchestrationReplayEventsError,30 OrchestrationReplayEventsError,
31 FilesystemBrowseError,31 FilesystemBrowseError,
32 ThreadId,32 ThreadId,
33 type TerminalAttachStreamEvent,
34 type TerminalError,
33 type TerminalEvent,35 type TerminalEvent,
36 type TerminalMetadataStreamEvent,
34 WS_METHODS,37 WS_METHODS,
35 WsRpcGroup,38 WsRpcGroup,
36} from "@t3tools/contracts";39} from "@t3tools/contracts";
...@@ -62,6 +65,7 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat...@@ -62,6 +65,7 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat
62import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts";65import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts";
63import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts";66import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts";
64import { GitWorkflowService } from "./git/GitWorkflowService.ts";67import { GitWorkflowService } from "./git/GitWorkflowService.ts";
68import { ReviewService } from "./review/ReviewService.ts";
65import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts";69import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts";
66import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts";70import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts";
67import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";71import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
...@@ -168,6 +172,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -168,6 +172,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
168 const keybindings = yield* Keybindings;172 const keybindings = yield* Keybindings;
169 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;173 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
170 const gitWorkflow = yield* GitWorkflowService;174 const gitWorkflow = yield* GitWorkflowService;
175 const review = yield* ReviewService;
171 const vcsProvisioning = yield* VcsProvisioningService;176 const vcsProvisioning = yield* VcsProvisioningService;
172 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;177 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
173 const terminalManager = yield* TerminalManager;178 const terminalManager = yield* TerminalManager;
...@@ -1119,10 +1124,25 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -1119,10 +1124,25 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
1119 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),1124 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
1120 { "rpc.aggregate": "vcs" },1125 { "rpc.aggregate": "vcs" },
1121 ),1126 ),
1127 [WS_METHODS.reviewGetDiffPreview]: (input) =>
1128 observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), {
1129 "rpc.aggregate": "review",
1130 }),
1122 [WS_METHODS.terminalOpen]: (input) =>1131 [WS_METHODS.terminalOpen]: (input) =>
1123 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {1132 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
1124 "rpc.aggregate": "terminal",1133 "rpc.aggregate": "terminal",
1125 }),1134 }),
1135 [WS_METHODS.terminalAttach]: (input) =>
1136 observeRpcStream(
1137 WS_METHODS.terminalAttach,
1138 Stream.callback<TerminalAttachStreamEvent, TerminalError>((queue) =>
1139 Effect.acquireRelease(
1140 terminalManager.attachStream(input, (event) => Queue.offer(queue, event)),
1141 (unsubscribe) => Effect.sync(unsubscribe),
1142 ),
1143 ),
1144 { "rpc.aggregate": "terminal" },
1145 ),
1126 [WS_METHODS.terminalWrite]: (input) =>1146 [WS_METHODS.terminalWrite]: (input) =>
1127 observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), {1147 observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), {
1128 "rpc.aggregate": "terminal",1148 "rpc.aggregate": "terminal",
...@@ -1154,6 +1174,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -1154,6 +1174,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
1154 ),1174 ),
1155 { "rpc.aggregate": "terminal" },1175 { "rpc.aggregate": "terminal" },
1156 ),1176 ),
1177 [WS_METHODS.subscribeTerminalMetadata]: (_input) =>
1178 observeRpcStream(
1179 WS_METHODS.subscribeTerminalMetadata,
1180 Stream.callback<TerminalMetadataStreamEvent>((queue) =>
1181 Effect.acquireRelease(
1182 terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)),
1183 (unsubscribe) => Effect.sync(unsubscribe),
1184 ),
1185 ),
1186 { "rpc.aggregate": "terminal" },
1187 ),
1157 [WS_METHODS.subscribeServerConfig]: (_input) =>1188 [WS_METHODS.subscribeServerConfig]: (_input) =>
1158 observeRpcStreamEffect(1189 observeRpcStreamEffect(
1159 WS_METHODS.subscribeServerConfig,1190 WS_METHODS.subscribeServerConfig,
apps/web/src/components/BranchToolbarBranchSelector.tsx+37-37
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";2import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
3import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4import { LegendList, type LegendListRef } from "@legendapp/list/react";3import { LegendList, type LegendListRef } from "@legendapp/list/react";
5import { ChevronDownIcon } from "lucide-react";4import { ChevronDownIcon } from "lucide-react";
6import {5import {
...@@ -16,8 +15,8 @@ import {...@@ -16,8 +15,8 @@ import {
1615
17import { useComposerDraftStore, type DraftId } from "../composerDraftStore";16import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
18import { readEnvironmentApi } from "../environmentApi";17import { readEnvironmentApi } from "../environmentApi";
19import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";18import { useVcsStatus } from "../lib/vcsStatusState";
20import { useGitStatus } from "../lib/gitStatusState";19import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState";
21import { newCommandId } from "../lib/utils";20import { newCommandId } from "../lib/utils";
22import { cn } from "../lib/utils";21import { cn } from "../lib/utils";
23import { parsePullRequestReference } from "../pullRequestReference";22import { parsePullRequestReference } from "../pullRequestReference";
...@@ -59,6 +58,8 @@ interface BranchToolbarBranchSelectorProps {...@@ -59,6 +58,8 @@ interface BranchToolbarBranchSelectorProps {
59 onComposerFocusRequest?: () => void;58 onComposerFocusRequest?: () => void;
60}59}
6160
61const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
62
62function toBranchActionErrorMessage(error: unknown): string {63function toBranchActionErrorMessage(error: unknown): string {
63 return error instanceof Error ? error.message : "An error occurred.";64 return error instanceof Error ? error.message : "An error occurred.";
64}65}
...@@ -196,39 +197,27 @@ export function BranchToolbarBranchSelector({...@@ -196,39 +197,27 @@ export function BranchToolbarBranchSelector({
196 // ---------------------------------------------------------------------------197 // ---------------------------------------------------------------------------
197 // Git ref queries198 // Git ref queries
198 // ---------------------------------------------------------------------------199 // ---------------------------------------------------------------------------
199 const queryClient = useQueryClient();
200 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);200 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
201 const [branchQuery, setBranchQuery] = useState("");201 const [branchQuery, setBranchQuery] = useState("");
202 const deferredBranchQuery = useDeferredValue(branchQuery);202 const deferredBranchQuery = useDeferredValue(branchQuery);
203203
204 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });204 const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd });
205 const trimmedBranchQuery = branchQuery.trim();205 const trimmedBranchQuery = branchQuery.trim();
206 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();206 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
207207 const branchRefTarget = useMemo(
208 useEffect(() => {208 () => ({
209 if (!branchCwd) return;
210 void queryClient.prefetchInfiniteQuery(
211 gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }),
212 );
213 }, [branchCwd, environmentId, queryClient]);
214
215 const {
216 data: branchesSearchData,
217 fetchNextPage,
218 hasNextPage,
219 isFetchingNextPage,
220 isPending: isBranchesSearchPending,
221 } = useInfiniteQuery(
222 gitBranchSearchInfiniteQueryOptions({
223 environmentId,209 environmentId,
224 cwd: branchCwd,210 cwd: branchCwd,
225 query: deferredTrimmedBranchQuery,211 query: deferredTrimmedBranchQuery,
226 }),212 }),
213 [branchCwd, deferredTrimmedBranchQuery, environmentId],
227 );214 );
228 const refs = useMemo(215 const branchRefState = useVcsRefs(branchRefTarget);
229 () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [],216 const refs = branchRefState.data?.refs ?? EMPTY_REFS;
230 [branchesSearchData?.pages],217 const hasNextPage =
231 );218 branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined;
219 const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
220 const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null;
232 const currentGitBranch =221 const currentGitBranch =
233 branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;222 branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;
234 const sourceControlPresentation = useMemo(223 const sourceControlPresentation = useMemo(
...@@ -293,8 +282,8 @@ export function BranchToolbarBranchSelector({...@@ -293,8 +282,8 @@ export function BranchToolbarBranchSelector({
293 );282 );
294 const [isBranchActionPending, startBranchActionTransition] = useTransition();283 const [isBranchActionPending, startBranchActionTransition] = useTransition();
295 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;284 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
296 const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;285 const totalBranchCount = branchRefState.data?.totalCount ?? 0;
297 const branchStatusText = isBranchesSearchPending286 const branchStatusText = isInitialBranchesLoadPending
298 ? "Loading refs..."287 ? "Loading refs..."
299 : isFetchingNextPage288 : isFetchingNextPage
300 ? "Loading more refs..."289 ? "Loading more refs..."
...@@ -308,8 +297,8 @@ export function BranchToolbarBranchSelector({...@@ -308,8 +297,8 @@ export function BranchToolbarBranchSelector({
308 const runBranchAction = (action: () => Promise<void>) => {297 const runBranchAction = (action: () => Promise<void>) => {
309 startBranchActionTransition(async () => {298 startBranchActionTransition(async () => {
310 await action().catch(() => undefined);299 await action().catch(() => undefined);
311 await queryClient300 await vcsRefManager
312 .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) })301 .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
313 .catch(() => undefined);302 .catch(() => undefined);
314 });303 });
315 };304 };
...@@ -425,14 +414,25 @@ export function BranchToolbarBranchSelector({...@@ -425,14 +414,25 @@ export function BranchToolbarBranchSelector({
425 setBranchQuery("");414 setBranchQuery("");
426 return;415 return;
427 }416 }
428 void queryClient.invalidateQueries({417 void vcsRefManager
429 queryKey: gitQueryKeys.refs(environmentId, branchCwd),418 .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
430 });419 .catch(() => undefined);
431 },420 },
432 [branchCwd, environmentId, queryClient],421 [branchRefTarget],
433 );422 );
434423
435 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);424 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
425 const fetchNextBranchPage = useCallback(() => {
426 if (!hasNextPage || isFetchingNextPage) {
427 return;
428 }
429
430 setIsFetchingNextPage(true);
431 void vcsRefManager
432 .loadNext(branchRefTarget, undefined, { limit: 100 })
433 .catch(() => undefined)
434 .finally(() => setIsFetchingNextPage(false));
435 }, [branchRefTarget, hasNextPage, isFetchingNextPage]);
436 const maybeFetchNextBranchPage = useCallback(() => {436 const maybeFetchNextBranchPage = useCallback(() => {
437 if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {437 if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {
438 return;438 return;
...@@ -449,8 +449,8 @@ export function BranchToolbarBranchSelector({...@@ -449,8 +449,8 @@ export function BranchToolbarBranchSelector({
449 return;449 return;
450 }450 }
451451
452 void fetchNextPage().catch(() => undefined);452 fetchNextBranchPage();
453 }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);453 }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);
454 const branchListRef = useRef<LegendListRef | null>(null);454 const branchListRef = useRef<LegendListRef | null>(null);
455 const setBranchListRef = useCallback((element: HTMLDivElement | null) => {455 const setBranchListRef = useCallback((element: HTMLDivElement | null) => {
456 branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null;456 branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null;
...@@ -592,7 +592,7 @@ export function BranchToolbarBranchSelector({...@@ -592,7 +592,7 @@ export function BranchToolbarBranchSelector({
592 <ComboboxTrigger592 <ComboboxTrigger
593 render={<Button variant="ghost" size="xs" />}593 render={<Button variant="ghost" size="xs" />}
594 className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)}594 className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)}
595 disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending}595 disabled={isInitialBranchesLoadPending || isBranchActionPending}
596 >596 >
597 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>597 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
598 <ChevronDownIcon className="shrink-0" />598 <ChevronDownIcon className="shrink-0" />
...@@ -622,7 +622,7 @@ export function BranchToolbarBranchSelector({...@@ -622,7 +622,7 @@ export function BranchToolbarBranchSelector({
622 drawDistance={336}622 drawDistance={336}
623 onEndReached={() => {623 onEndReached={() => {
624 if (hasNextPage && !isFetchingNextPage) {624 if (hasNextPage && !isFetchingNextPage) {
625 void fetchNextPage().catch(() => undefined);625 fetchNextBranchPage();
626 }626 }
627 }}627 }}
628 style={{ maxHeight: "14rem" }}628 style={{ maxHeight: "14rem" }}
apps/web/src/components/ChatView.browser.tsx+176-32
...@@ -12,12 +12,14 @@ import {...@@ -12,12 +12,14 @@ import {
12 ProviderDriverKind,12 ProviderDriverKind,
13 ProviderInstanceId,13 ProviderInstanceId,
14 type ServerConfig,14 type ServerConfig,
15 type TerminalMetadataStreamEvent,
15 type ServerLifecycleWelcomePayload,16 type ServerLifecycleWelcomePayload,
16 type ThreadId,17 type ThreadId,
17 type TurnId,18 type TurnId,
18 WS_METHODS,19 WS_METHODS,
19 OrchestrationSessionStatus,20 OrchestrationSessionStatus,
20 DEFAULT_SERVER_SETTINGS,21 DEFAULT_SERVER_SETTINGS,
22 DEFAULT_TERMINAL_ID,
21 ServerConfig as ServerConfigSchema,23 ServerConfig as ServerConfigSchema,
22} from "@t3tools/contracts";24} from "@t3tools/contracts";
23import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";25import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
...@@ -55,19 +57,46 @@ import { getServerConfig } from "../rpc/serverState";...@@ -55,19 +57,46 @@ import { getServerConfig } from "../rpc/serverState";
55import { getRouter } from "../router";57import { getRouter } from "../router";
56import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";58import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
57import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";59import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
58import { useTerminalStateStore } from "../terminalStateStore";60import { terminalSessionManager } from "../terminalSessionState";
61import { useTerminalUiStateStore } from "../terminalUiStateStore";
59import { useUiStateStore } from "../uiStateStore";62import { useUiStateStore } from "../uiStateStore";
60import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";63import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
61import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";64import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
6265
63import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";66import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
6467
65vi.mock("../lib/gitStatusState", () => ({68vi.mock("../lib/vcsStatusState", () => {
66 useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }),69 const status = {
67 useGitStatuses: () => new Map(),70 data: {
68 refreshGitStatus: () => Promise.resolve(null),71 isRepo: true,
69 resetGitStatusStateForTests: () => undefined,72 sourceControlProvider: {
70}));73 kind: "github",
74 name: "GitHub",
75 baseUrl: "https://github.com",
76 },
77 hasPrimaryRemote: true,
78 isDefaultRef: true,
79 refName: "main",
80 hasWorkingTreeChanges: false,
81 workingTree: { files: [], insertions: 0, deletions: 0 },
82 hasUpstream: true,
83 aheadCount: 0,
84 behindCount: 0,
85 pr: null,
86 },
87 error: null,
88 cause: null,
89 isPending: false,
90 };
91
92 return {
93 getVcsStatusSnapshot: () => status,
94 useVcsStatus: () => status,
95 useVcsStatuses: () => new Map(),
96 refreshVcsStatus: () => Promise.resolve(null),
97 resetVcsStatusStateForTests: () => undefined,
98 };
99});
71100
72const THREAD_ID = "thread-browser-test" as ThreadId;101const THREAD_ID = "thread-browser-test" as ThreadId;
73const THREAD_TITLE = "Browser test thread";102const THREAD_TITLE = "Browser test thread";
...@@ -101,6 +130,7 @@ interface TestFixture {...@@ -101,6 +130,7 @@ interface TestFixture {
101 snapshot: OrchestrationReadModel;130 snapshot: OrchestrationReadModel;
102 serverConfig: ServerConfig;131 serverConfig: ServerConfig;
103 welcome: ServerLifecycleWelcomePayload;132 welcome: ServerLifecycleWelcomePayload;
133 terminalMetadataEvents: ReadonlyArray<TerminalMetadataStreamEvent>;
104}134}
105135
106let fixture: TestFixture;136let fixture: TestFixture;
...@@ -213,6 +243,7 @@ function createMockEnvironmentApi(input: {...@@ -213,6 +243,7 @@ function createMockEnvironmentApi(input: {
213 sourceControl: {} as EnvironmentApi["sourceControl"],243 sourceControl: {} as EnvironmentApi["sourceControl"],
214 vcs: {} as EnvironmentApi["vcs"],244 vcs: {} as EnvironmentApi["vcs"],
215 git: {} as EnvironmentApi["git"],245 git: {} as EnvironmentApi["git"],
246 review: {} as EnvironmentApi["review"],
216 orchestration: {247 orchestration: {
217 dispatchCommand: input.dispatchCommand,248 dispatchCommand: input.dispatchCommand,
218 getTurnDiff: (() => {249 getTurnDiff: (() => {
...@@ -398,6 +429,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {...@@ -398,6 +429,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
398 bootstrapProjectId: PROJECT_ID,429 bootstrapProjectId: PROJECT_ID,
399 bootstrapThreadId: THREAD_ID,430 bootstrapThreadId: THREAD_ID,
400 },431 },
432 terminalMetadataEvents: [],
401 };433 };
402}434}
403435
...@@ -1068,6 +1100,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {...@@ -1068,6 +1100,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
1068 history: "",1100 history: "",
1069 exitCode: null,1101 exitCode: null,
1070 exitSignal: null,1102 exitSignal: null,
1103 label: "Terminal 1",
1071 updatedAt: NOW_ISO,1104 updatedAt: NOW_ISO,
1072 };1105 };
1073 }1106 }
...@@ -1691,6 +1724,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1691,6 +1724,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
1691 ]1724 ]
1692 : [];1725 : [];
1693 }1726 }
1727 if (request._tag === WS_METHODS.subscribeTerminalMetadata) {
1728 return fixture.terminalMetadataEvents;
1729 }
1694 return [];1730 return [];
1695 },1731 },
1696 });1732 });
...@@ -1724,12 +1760,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1724,12 +1760,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
1724 projectOrder: [],1760 projectOrder: [],
1725 threadLastVisitedAtById: {},1761 threadLastVisitedAtById: {},
1726 });1762 });
1727 useTerminalStateStore.persist.clearStorage();1763 useTerminalUiStateStore.persist.clearStorage();
1728 useTerminalStateStore.setState({1764 useTerminalUiStateStore.setState({
1729 terminalStateByThreadKey: {},1765 terminalUiStateByThreadKey: {},
1730 terminalLaunchContextByThreadKey: {},
1731 terminalEventEntriesByKey: {},
1732 nextTerminalEventId: 1,
1733 });1766 });
1734 });1767 });
17351768
...@@ -1906,37 +1939,50 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1906,37 +1939,50 @@ describe("ChatView timeline estimator parity (full app)", () => {
1906 });1939 });
1907 }1940 }
19081941
1909 useTerminalStateStore.setState({1942 useTerminalUiStateStore.setState({
1910 terminalStateByThreadKey: {1943 terminalUiStateByThreadKey: {
1911 [THREAD_KEY]: {1944 [THREAD_KEY]: {
1912 terminalOpen: true,1945 terminalOpen: true,
1913 terminalHeight: 280,1946 terminalHeight: 280,
1914 terminalIds: ["default"],1947 terminalIds: ["default"],
1915 runningTerminalIds: [],
1916 activeTerminalId: "default",1948 activeTerminalId: "default",
1917 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],1949 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
1918 activeTerminalGroupId: "group-default",1950 activeTerminalGroupId: "group-default",
1919 },1951 },
1920 },1952 },
1921 terminalLaunchContextByThreadKey: {
1922 [THREAD_KEY]: {
1923 cwd: "/repo/project",
1924 worktreePath: null,
1925 },
1926 },
1927 });1953 });
19281954
1929 const mounted = await mountChatView({1955 const mounted = await mountChatView({
1930 viewport: DEFAULT_VIEWPORT,1956 viewport: DEFAULT_VIEWPORT,
1931 snapshot,1957 snapshot,
1958 configureFixture: (nextFixture) => {
1959 nextFixture.terminalMetadataEvents = [
1960 {
1961 type: "upsert",
1962 terminal: {
1963 threadId: THREAD_ID,
1964 terminalId: DEFAULT_TERMINAL_ID,
1965 cwd: "/repo/project",
1966 worktreePath: null,
1967 status: "running",
1968 pid: 123,
1969 exitCode: null,
1970 exitSignal: null,
1971 hasRunningSubprocess: false,
1972 label: "Terminal 1",
1973 updatedAt: isoAt(0),
1974 },
1975 },
1976 ];
1977 },
1932 });1978 });
19331979
1934 try {1980 try {
1935 await vi.waitFor(1981 await vi.waitFor(
1936 () => {1982 () => {
1937 const openRequest = wsRequests.find(1983 const attachRequest = wsRequests
1938 (request) => request._tag === WS_METHODS.terminalOpen,1984 .toReversed()
1939 ) as1985 .find((request) => request._tag === WS_METHODS.terminalAttach) as
1940 | {1986 | {
1941 _tag: string;1987 _tag: string;
1942 cwd?: string;1988 cwd?: string;
...@@ -1944,15 +1990,51 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1944,15 +1990,51 @@ describe("ChatView timeline estimator parity (full app)", () => {
1944 env?: Record<string, string>;1990 env?: Record<string, string>;
1945 }1991 }
1946 | undefined;1992 | undefined;
1947 expect(openRequest).toMatchObject({1993 expect(attachRequest).toMatchObject({
1948 _tag: WS_METHODS.terminalOpen,1994 _tag: WS_METHODS.terminalAttach,
1949 cwd: "/repo/project",1995 cwd: "/repo/project",
1950 worktreePath: null,1996 worktreePath: null,
1951 env: {1997 env: {
1952 T3CODE_PROJECT_ROOT: "/repo/project",1998 T3CODE_PROJECT_ROOT: "/repo/project",
1953 },1999 },
1954 });2000 });
1955 expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined();2001 expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined();
2002 },
2003 { timeout: 8_000, interval: 16 },
2004 );
2005 } finally {
2006 await mounted.cleanup();
2007 }
2008 });
2009
2010 it("attaches the default terminal when opening an empty terminal drawer", async () => {
2011 const mounted = await mountChatView({
2012 viewport: DEFAULT_VIEWPORT,
2013 snapshot: createSnapshotForTargetUser({
2014 targetMessageId: "msg-user-open-empty-terminal-drawer" as MessageId,
2015 targetText: "open empty terminal drawer",
2016 }),
2017 });
2018
2019 try {
2020 const toggle = await waitForElement(
2021 () =>
2022 document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'),
2023 "Unable to find terminal drawer toggle.",
2024 );
2025 toggle.click();
2026
2027 await vi.waitFor(
2028 () => {
2029 const attachRequest = wsRequests.find(
2030 (request) => request._tag === WS_METHODS.terminalAttach,
2031 );
2032 expect(attachRequest).toMatchObject({
2033 _tag: WS_METHODS.terminalAttach,
2034 threadId: THREAD_ID,
2035 terminalId: DEFAULT_TERMINAL_ID,
2036 cwd: "/repo/project",
2037 });
1956 },2038 },
1957 { timeout: 8_000, interval: 16 },2039 { timeout: 8_000, interval: 16 },
1958 );2040 );
...@@ -2483,8 +2565,8 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2483,8 +2565,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
2483 });2565 });
24842566
2485 it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => {2567 it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => {
2486 useTerminalStateStore.setState({2568 useTerminalUiStateStore.setState({
2487 terminalStateByThreadKey: {},2569 terminalUiStateByThreadKey: {},
2488 });2570 });
2489 useComposerDraftStore.setState({2571 useComposerDraftStore.setState({
2490 draftThreadsByThreadKey: {2572 draftThreadsByThreadKey: {
...@@ -2987,8 +3069,8 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2987,8 +3069,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
2987 });3069 });
29883070
2989 it("shows the send state once bootstrap dispatch is in flight", async () => {3071 it("shows the send state once bootstrap dispatch is in flight", async () => {
2990 useTerminalStateStore.setState({3072 useTerminalUiStateStore.setState({
2991 terminalStateByThreadKey: {},3073 terminalUiStateByThreadKey: {},
2992 });3074 });
2993 useComposerDraftStore.setState({3075 useComposerDraftStore.setState({
2994 draftThreadsByThreadKey: {3076 draftThreadsByThreadKey: {
...@@ -3811,6 +3893,68 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3811,6 +3893,68 @@ describe("ChatView timeline estimator parity (full app)", () => {
3811 }3893 }
3812 });3894 });
38133895
3896 it("shows the sidebar terminal indicator from terminal metadata activity", async () => {
3897 const mounted = await mountChatView({
3898 viewport: DEFAULT_VIEWPORT,
3899 snapshot: createSnapshotForTargetUser({
3900 targetMessageId: "msg-user-terminal-metadata-indicator" as MessageId,
3901 targetText: "terminal metadata indicator target",
3902 }),
3903 configureFixture: (nextFixture) => {
3904 nextFixture.terminalMetadataEvents = [
3905 {
3906 type: "upsert",
3907 terminal: {
3908 threadId: THREAD_ID,
3909 terminalId: DEFAULT_TERMINAL_ID,
3910 cwd: "/repo/project",
3911 worktreePath: null,
3912 status: "running",
3913 pid: 123,
3914 exitCode: null,
3915 exitSignal: null,
3916 hasRunningSubprocess: true,
3917 label: "Terminal 1",
3918 updatedAt: isoAt(1_200),
3919 },
3920 },
3921 ];
3922 },
3923 });
3924
3925 try {
3926 await vi.waitFor(
3927 () => {
3928 expect(
3929 terminalSessionManager.listSessions({
3930 environmentId: LOCAL_ENVIRONMENT_ID,
3931 threadId: THREAD_ID,
3932 }),
3933 ).toMatchObject([
3934 {
3935 state: {
3936 hasRunningSubprocess: true,
3937 },
3938 },
3939 ]);
3940 },
3941 { timeout: 8_000, interval: 16 },
3942 );
3943
3944 await vi.waitFor(
3945 () => {
3946 const terminalIndicator = document.querySelector<HTMLElement>(
3947 '[aria-label="Terminal process running"]',
3948 );
3949 expect(terminalIndicator).not.toBeNull();
3950 },
3951 { timeout: 8_000, interval: 16 },
3952 );
3953 } finally {
3954 await mounted.cleanup();
3955 }
3956 });
3957
3814 it("shows the confirm archive action after clicking the archive button", async () => {3958 it("shows the confirm archive action after clicking the archive button", async () => {
3815 localStorage.setItem(3959 localStorage.setItem(
3816 "t3code:client-settings:v1",3960 "t3code:client-settings:v1",
apps/web/src/components/ChatView.tsx+385-162
...@@ -34,11 +34,12 @@ import {...@@ -34,11 +34,12 @@ import {
34} from "@t3tools/shared/model";34} from "@t3tools/shared/model";
35import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";35import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
36import { truncate } from "@t3tools/shared/String";36import { truncate } from "@t3tools/shared/String";
37import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
37import { Debouncer } from "@tanstack/react-pacer";38import { Debouncer } from "@tanstack/react-pacer";
38import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";39import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
39import { useNavigate, useSearch } from "@tanstack/react-router";40import { useNavigate, useSearch } from "@tanstack/react-router";
40import { useShallow } from "zustand/react/shallow";41import { useShallow } from "zustand/react/shallow";
41import { useGitStatus } from "~/lib/gitStatusState";42import { useVcsStatus } from "~/lib/vcsStatusState";
42import { usePrimaryEnvironmentId } from "../environments/primary";43import { usePrimaryEnvironmentId } from "../environments/primary";
43import { readEnvironmentApi } from "../environmentApi";44import { readEnvironmentApi } from "../environmentApi";
44import { isElectron } from "../env";45import { isElectron } from "../env";
...@@ -105,7 +106,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"...@@ -105,7 +106,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"
105import PlanSidebar from "./PlanSidebar";106import PlanSidebar from "./PlanSidebar";
106import ThreadTerminalDrawer from "./ThreadTerminalDrawer";107import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
107import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";108import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
108import { cn, randomHex, randomUUID } from "~/lib/utils";109import { cn, randomHex } from "~/lib/utils";
109import { stackedThreadToast, toastManager } from "./ui/toast";110import { stackedThreadToast, toastManager } from "./ui/toast";
110import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";111import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
111import { type NewProjectScriptInput } from "./ProjectScriptsControl";112import { type NewProjectScriptInput } from "./ProjectScriptsControl";
...@@ -141,7 +142,8 @@ import {...@@ -141,7 +142,8 @@ import {
141 type TerminalContextDraft,142 type TerminalContextDraft,
142 type TerminalContextSelection,143 type TerminalContextSelection,
143} from "../lib/terminalContext";144} from "../lib/terminalContext";
144import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";145import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
146import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState";
145import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";147import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
146import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";148import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
147import { PullRequestThreadDialog } from "./PullRequestThreadDialog";149import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
...@@ -428,6 +430,45 @@ function useLocalDispatchState(input: {...@@ -428,6 +430,45 @@ function useLocalDispatchState(input: {
428 };430 };
429}431}
430432
433/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */
434function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean {
435 if (left.length !== right.length) {
436 return false;
437 }
438 if (left.length === 0) {
439 return true;
440 }
441 const sortedLeft = left.toSorted((a, b) => a.localeCompare(b));
442 const sortedRight = right.toSorted((a, b) => a.localeCompare(b));
443 for (let index = 0; index < sortedLeft.length; index += 1) {
444 if (sortedLeft[index] !== sortedRight[index]) {
445 return false;
446 }
447 }
448 return true;
449}
450
451/**
452 * Server knows about fewer sessions than the client, but every server id still exists locally.
453 * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id
454 * and later re-add it as a separate group (no split layout).
455 */
456function serverTerminalIdsStrictSubsetOfClient(
457 serverIds: readonly string[],
458 clientIds: readonly string[],
459): boolean {
460 if (serverIds.length >= clientIds.length || clientIds.length === 0) {
461 return false;
462 }
463 const clientSet = new Set(clientIds);
464 for (const id of serverIds) {
465 if (!clientSet.has(id)) {
466 return false;
467 }
468 }
469 return true;
470}
471
431interface PersistentThreadTerminalDrawerProps {472interface PersistentThreadTerminalDrawerProps {
432 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };473 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
433 threadId: ThreadId;474 threadId: ThreadId;
...@@ -461,14 +502,77 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -461,14 +502,77 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
461 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)502 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
462 : null;503 : null;
463 const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef]));504 const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef]));
464 const terminalState = useTerminalStateStore((state) =>505 const terminalUiState = useTerminalUiStateStore((state) =>
465 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef),506 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
466 );507 );
467 const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight);508 const knownTerminalSessions = useKnownTerminalSessions({
468 const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal);509 environmentId: threadRef.environmentId,
469 const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal);510 threadId,
470 const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal);511 });
471 const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal);512 const terminalLabelsById = useMemo(() => {
513 const next = new Map<string, string>();
514 for (const session of knownTerminalSessions) {
515 next.set(
516 session.target.terminalId,
517 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
518 );
519 }
520 return next;
521 }, [knownTerminalSessions]);
522 const terminalLaunchLocationsById = useMemo(() => {
523 const next = new Map<
524 string,
525 {
526 readonly cwd: string;
527 readonly worktreePath: string | null;
528 readonly runtimeEnv: Record<string, string>;
529 }
530 >();
531 if (!project) {
532 return next;
533 }
534
535 for (const session of knownTerminalSessions) {
536 const summary = session.state.summary;
537 if (!summary) {
538 continue;
539 }
540 const worktreePathForLaunch =
541 launchContext !== null ? launchContext.worktreePath : summary.worktreePath;
542 next.set(session.target.terminalId, {
543 cwd: launchContext?.cwd ?? summary.cwd,
544 worktreePath: worktreePathForLaunch,
545 runtimeEnv: projectScriptRuntimeEnv({
546 project: { cwd: project.cwd },
547 worktreePath: worktreePathForLaunch,
548 }),
549 });
550 }
551
552 return next;
553 }, [knownTerminalSessions, launchContext, project]);
554 const serverOrderedTerminalIds = useMemo(
555 () => knownTerminalSessions.map((session) => session.target.terminalId),
556 [knownTerminalSessions],
557 );
558 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
559 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
560 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);
561 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);
562 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);
563 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
564
565 useEffect(() => {
566 if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) {
567 return;
568 }
569 if (
570 serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds)
571 ) {
572 return;
573 }
574 reconcileTerminalIds(threadRef, serverOrderedTerminalIds);
575 }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]);
472 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);576 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
473 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;577 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
474 const effectiveWorktreePath = useMemo(() => {578 const effectiveWorktreePath = useMemo(() => {
...@@ -514,14 +618,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -514,14 +618,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
514 );618 );
515619
516 const splitTerminal = useCallback(() => {620 const splitTerminal = useCallback(() => {
517 storeSplitTerminal(threadRef, `terminal-${randomUUID()}`);621 const api = readEnvironmentApi(threadRef.environmentId);
622 if (!api || !cwd) {
623 return;
624 }
625 const terminalId = nextTerminalId(serverOrderedTerminalIds);
626 storeSplitTerminal(threadRef, terminalId);
518 bumpFocusRequestId();627 bumpFocusRequestId();
519 }, [bumpFocusRequestId, storeSplitTerminal, threadRef]);628 void (async () => {
629 try {
630 await api.terminal.open({
631 threadId,
632 terminalId,
633 cwd,
634 ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
635 env: runtimeEnv,
636 });
637 } catch {
638 // Opening failed; the tab is already in the store — user can retry or close it.
639 }
640 })();
641 }, [
642 bumpFocusRequestId,
643 cwd,
644 effectiveWorktreePath,
645 runtimeEnv,
646 serverOrderedTerminalIds,
647 storeSplitTerminal,
648 threadId,
649 threadRef,
650 ]);
520651
521 const createNewTerminal = useCallback(() => {652 const createNewTerminal = useCallback(() => {
522 storeNewTerminal(threadRef, `terminal-${randomUUID()}`);653 const api = readEnvironmentApi(threadRef.environmentId);
654 if (!api || !cwd) {
655 return;
656 }
657 const terminalId = nextTerminalId(serverOrderedTerminalIds);
658 storeNewTerminal(threadRef, terminalId);
523 bumpFocusRequestId();659 bumpFocusRequestId();
524 }, [bumpFocusRequestId, storeNewTerminal, threadRef]);660 void (async () => {
661 try {
662 await api.terminal.open({
663 threadId,
664 terminalId,
665 cwd,
666 ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
667 env: runtimeEnv,
668 });
669 } catch {
670 // Opening failed; the tab is already in the store — user can retry or close it.
671 }
672 })();
673 }, [
674 bumpFocusRequestId,
675 cwd,
676 effectiveWorktreePath,
677 runtimeEnv,
678 serverOrderedTerminalIds,
679 storeNewTerminal,
680 threadId,
681 threadRef,
682 ]);
525683
526 const activateTerminal = useCallback(684 const activateTerminal = useCallback(
527 (terminalId: string) => {685 (terminalId: string) => {
...@@ -535,7 +693,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -535,7 +693,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
535 (terminalId: string) => {693 (terminalId: string) => {
536 const api = readEnvironmentApi(threadRef.environmentId);694 const api = readEnvironmentApi(threadRef.environmentId);
537 if (!api) return;695 if (!api) return;
538 const isFinalTerminal = terminalState.terminalIds.length <= 1;696 const isFinalTerminal = terminalUiState.terminalIds.length <= 1;
539 const fallbackExitWrite = () =>697 const fallbackExitWrite = () =>
540 api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined);698 api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined);
541699
...@@ -557,7 +715,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -557,7 +715,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
557 storeCloseTerminal(threadRef, terminalId);715 storeCloseTerminal(threadRef, terminalId);
558 bumpFocusRequestId();716 bumpFocusRequestId();
559 },717 },
560 [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef],718 [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef],
561 );719 );
562720
563 const handleAddTerminalContext = useCallback(721 const handleAddTerminalContext = useCallback(
...@@ -570,7 +728,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -570,7 +728,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
570 [onAddTerminalContext, visible],728 [onAddTerminalContext, visible],
571 );729 );
572730
573 if (!project || !terminalState.terminalOpen || !cwd) {731 if (!project || !terminalUiState.terminalOpen || !cwd) {
574 return null;732 return null;
575 }733 }
576734
...@@ -583,11 +741,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -583,11 +741,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
583 worktreePath={effectiveWorktreePath}741 worktreePath={effectiveWorktreePath}
584 runtimeEnv={runtimeEnv}742 runtimeEnv={runtimeEnv}
585 visible={visible}743 visible={visible}
586 height={terminalState.terminalHeight}744 height={terminalUiState.terminalHeight}
587 terminalIds={terminalState.terminalIds}745 // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
588 activeTerminalId={terminalState.activeTerminalId}746 terminalIds={terminalUiState.terminalIds}
589 terminalGroups={terminalState.terminalGroups}747 activeTerminalId={terminalUiState.activeTerminalId}
590 activeTerminalGroupId={terminalState.activeTerminalGroupId}748 terminalGroups={terminalUiState.terminalGroups}
749 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
591 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}750 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
592 onSplitTerminal={splitTerminal}751 onSplitTerminal={splitTerminal}
593 onNewTerminal={createNewTerminal}752 onNewTerminal={createNewTerminal}
...@@ -599,6 +758,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -599,6 +758,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
599 onCloseTerminal={closeTerminal}758 onCloseTerminal={closeTerminal}
600 onHeightChange={setTerminalHeight}759 onHeightChange={setTerminalHeight}
601 onAddTerminalContext={handleAddTerminalContext}760 onAddTerminalContext={handleAddTerminalContext}
761 terminalLabelsById={terminalLabelsById}
762 terminalLaunchLocationsById={terminalLaunchLocationsById}
602 />763 />
603 </div>764 </div>
604 );765 );
...@@ -713,9 +874,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -713,9 +874,8 @@ export default function ChatView(props: ChatViewProps) {
713 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);874 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
714 const [pullRequestDialogState, setPullRequestDialogState] =875 const [pullRequestDialogState, setPullRequestDialogState] =
715 useState<PullRequestDialogState | null>(null);876 useState<PullRequestDialogState | null>(null);
716 const [terminalLaunchContext, setTerminalLaunchContext] = useState<TerminalLaunchContext | null>(877 const [terminalUiLaunchContext, setTerminalUiLaunchContext] =
717 null,878 useState<TerminalLaunchContext | null>(null);
718 );
719 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<879 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
720 Record<string, string[]>880 Record<string, string[]>
721 >({});881 >({});
...@@ -732,23 +892,24 @@ export default function ChatView(props: ChatViewProps) {...@@ -732,23 +892,24 @@ export default function ChatView(props: ChatViewProps) {
732 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});892 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
733 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});893 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
734 const sendInFlightRef = useRef(false);894 const sendInFlightRef = useRef(false);
735 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});895 const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});
736896
737 const terminalState = useTerminalStateStore((state) =>897 const terminalUiState = useTerminalUiStateStore((state) =>
738 selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef),898 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef),
739 );899 );
740 const openTerminalThreadKeys = useTerminalStateStore(900 const openTerminalThreadKeys = useTerminalUiStateStore(
741 useShallow((state) =>901 useShallow((state) =>
742 Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) =>902 Object.entries(state.terminalUiStateByThreadKey).flatMap(
743 nextTerminalState.terminalOpen ? [nextThreadKey] : [],903 ([nextThreadKey, nextTerminalUiState]) =>
904 nextTerminalUiState.terminalOpen ? [nextThreadKey] : [],
744 ),905 ),
745 ),906 ),
746 );907 );
747 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);908 const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen);
748 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);909 const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal);
749 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);910 const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal);
750 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);911 const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal);
751 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);912 const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal);
752 const serverThreadKeys = useStore(913 const serverThreadKeys = useStore(
753 useShallow((state) =>914 useShallow((state) =>
754 selectThreadsAcrossEnvironments(state).map((thread) =>915 selectThreadsAcrossEnvironments(state).map((thread) =>
...@@ -756,12 +917,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -756,12 +917,6 @@ export default function ChatView(props: ChatViewProps) {
756 ),917 ),
757 ),918 ),
758 );919 );
759 const storeServerTerminalLaunchContext = useTerminalStateStore(
760 (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null,
761 );
762 const storeClearTerminalLaunchContext = useTerminalStateStore(
763 (s) => s.clearTerminalLaunchContext,
764 );
765 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);920 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
766 const draftThreadKeys = useMemo(921 const draftThreadKeys = useMemo(
767 () =>922 () =>
...@@ -814,11 +969,60 @@ export default function ChatView(props: ChatViewProps) {...@@ -814,11 +969,60 @@ export default function ChatView(props: ChatViewProps) {
814 const canCheckoutPullRequestIntoThread = isLocalDraftThread;969 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
815 const diffOpen = rawSearch.diff === "1";970 const diffOpen = rawSearch.diff === "1";
816 const activeThreadId = activeThread?.id ?? null;971 const activeThreadId = activeThread?.id ?? null;
972 const runningTerminalIds = useThreadRunningTerminalIds({
973 environmentId: activeThread?.environmentId ?? null,
974 threadId: activeThreadId,
975 });
976 const activeThreadKnownSessionsRaw = useKnownTerminalSessions({
977 environmentId: activeThread?.environmentId ?? null,
978 threadId: activeThreadId,
979 });
980 const activeThreadKnownSessions = useMemo(() => {
981 if (activeThreadId === null) {
982 return [];
983 }
984 return activeThreadKnownSessionsRaw.filter(
985 (session) => session.target.threadId === activeThreadId,
986 );
987 }, [activeThreadId, activeThreadKnownSessionsRaw]);
988 const activeServerOrderedTerminalIds = useMemo(
989 () => activeThreadKnownSessions.map((session) => session.target.terminalId),
990 [activeThreadKnownSessions],
991 );
992 const activeKnownTerminalIds = useMemo(
993 () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])],
994 [activeServerOrderedTerminalIds, terminalUiState.terminalIds],
995 );
996 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
817 const activeThreadRef = useMemo(997 const activeThreadRef = useMemo(
818 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),998 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
819 [activeThread],999 [activeThread],
820 );1000 );
821 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1001 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1002
1003 useEffect(() => {
1004 if (!activeThreadRef) {
1005 return;
1006 }
1007 if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) {
1008 return;
1009 }
1010 if (
1011 serverTerminalIdsStrictSubsetOfClient(
1012 activeServerOrderedTerminalIds,
1013 terminalUiState.terminalIds,
1014 )
1015 ) {
1016 return;
1017 }
1018 reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds);
1019 }, [
1020 activeThreadRef,
1021 activeServerOrderedTerminalIds,
1022 reconcileTerminalIds,
1023 terminalUiState.terminalIds,
1024 ]);
1025
822 const existingOpenTerminalThreadKeys = useMemo(() => {1026 const existingOpenTerminalThreadKeys = useMemo(() => {
823 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);1027 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
824 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));1028 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
...@@ -843,7 +1047,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -843,7 +1047,7 @@ export default function ChatView(props: ChatViewProps) {
843 currentThreadIds,1047 currentThreadIds,
844 openThreadIds: existingOpenTerminalThreadKeys,1048 openThreadIds: existingOpenTerminalThreadKeys,
845 activeThreadId: activeThreadKey,1049 activeThreadId: activeThreadKey,
846 activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen),1050 activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen),
847 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,1051 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
848 });1052 });
849 return currentThreadIds.length === nextThreadIds.length &&1053 return currentThreadIds.length === nextThreadIds.length &&
...@@ -851,7 +1055,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -851,7 +1055,7 @@ export default function ChatView(props: ChatViewProps) {
851 ? currentThreadIds1055 ? currentThreadIds
852 : nextThreadIds;1056 : nextThreadIds;
853 });1057 });
854 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]);1058 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);
855 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);1059 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
856 const activeProjectRef = activeThread1060 const activeProjectRef = activeThread
857 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)1061 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
...@@ -1631,7 +1835,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1631,7 +1835,7 @@ export default function ChatView(props: ChatViewProps) {
1631 worktreePath: activeThread?.worktreePath ?? null,1835 worktreePath: activeThread?.worktreePath ?? null,
1632 })1836 })
1633 : null;1837 : null;
1634 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });1838 const gitStatusQuery = useVcsStatus({ environmentId, cwd: gitCwd });
1635 const keybindings = useServerKeybindings();1839 const keybindings = useServerKeybindings();
1636 const availableEditors = useServerAvailableEditors();1840 const availableEditors = useServerAvailableEditors();
1637 // Prefer an instance-id match so a custom Codex instance (e.g.1841 // Prefer an instance-id match so a custom Codex instance (e.g.
...@@ -1656,28 +1860,26 @@ export default function ChatView(props: ChatViewProps) {...@@ -1656,28 +1860,26 @@ export default function ChatView(props: ChatViewProps) {
1656 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;1860 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
1657 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;1861 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
1658 const activeTerminalLaunchContext =1862 const activeTerminalLaunchContext =
1659 terminalLaunchContext?.threadId === activeThreadId1863 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
1660 ? terminalLaunchContext
1661 : (storeServerTerminalLaunchContext ?? null);
1662 // Default true while loading to avoid toolbar flicker.1864 // Default true while loading to avoid toolbar flicker.
1663 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;1865 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
1664 const terminalShortcutLabelOptions = useMemo(1866 const terminalShortcutLabelOptions = useMemo(
1665 () => ({1867 () => ({
1666 context: {1868 context: {
1667 terminalFocus: true,1869 terminalFocus: true,
1668 terminalOpen: Boolean(terminalState.terminalOpen),1870 terminalOpen: Boolean(terminalUiState.terminalOpen),
1669 },1871 },
1670 }),1872 }),
1671 [terminalState.terminalOpen],1873 [terminalUiState.terminalOpen],
1672 );1874 );
1673 const nonTerminalShortcutLabelOptions = useMemo(1875 const nonTerminalShortcutLabelOptions = useMemo(
1674 () => ({1876 () => ({
1675 context: {1877 context: {
1676 terminalFocus: false,1878 terminalFocus: false,
1677 terminalOpen: Boolean(terminalState.terminalOpen),1879 terminalOpen: Boolean(terminalUiState.terminalOpen),
1678 },1880 },
1679 }),1881 }),
1680 [terminalState.terminalOpen],1882 [terminalUiState.terminalOpen],
1681 );1883 );
1682 const terminalToggleShortcutLabel = useMemo(1884 const terminalToggleShortcutLabel = useMemo(
1683 () => shortcutLabelForCommand(keybindings, "terminal.toggle"),1885 () => shortcutLabelForCommand(keybindings, "terminal.toggle"),
...@@ -1744,11 +1946,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -1744,11 +1946,11 @@ export default function ChatView(props: ChatViewProps) {
1744 );1946 );
17451947
1746 const activeTerminalGroup =1948 const activeTerminalGroup =
1747 terminalState.terminalGroups.find(1949 terminalUiState.terminalGroups.find(
1748 (group) => group.id === terminalState.activeTerminalGroupId,1950 (group) => group.id === terminalUiState.activeTerminalGroupId,
1749 ) ??1951 ) ??
1750 terminalState.terminalGroups.find((group) =>1952 terminalUiState.terminalGroups.find((group) =>
1751 group.terminalIds.includes(terminalState.activeTerminalId),1953 group.terminalIds.includes(terminalUiState.activeTerminalId),
1752 ) ??1954 ) ??
1753 null;1955 null;
1754 const hasReachedSplitLimit =1956 const hasReachedSplitLimit =
...@@ -1800,25 +2002,96 @@ export default function ChatView(props: ChatViewProps) {...@@ -1800,25 +2002,96 @@ export default function ChatView(props: ChatViewProps) {
1800 );2002 );
1801 const toggleTerminalVisibility = useCallback(() => {2003 const toggleTerminalVisibility = useCallback(() => {
1802 if (!activeThreadRef) return;2004 if (!activeThreadRef) return;
1803 setTerminalOpen(!terminalState.terminalOpen);2005 setTerminalOpen(!terminalUiState.terminalOpen);
1804 }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]);2006 }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]);
1805 const splitTerminal = useCallback(() => {2007 const splitTerminal = useCallback(() => {
1806 if (!activeThreadRef || hasReachedSplitLimit) return;2008 if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {
1807 const terminalId = `terminal-${randomUUID()}`;2009 return;
2010 }
2011 const cwdForOpen = gitCwd ?? activeProject.cwd;
2012 if (!cwdForOpen) {
2013 return;
2014 }
2015 const api = readEnvironmentApi(environmentId);
2016 if (!api) {
2017 return;
2018 }
2019 const terminalId = nextTerminalId(activeKnownTerminalIds);
1808 storeSplitTerminal(activeThreadRef, terminalId);2020 storeSplitTerminal(activeThreadRef, terminalId);
1809 setTerminalFocusRequestId((value) => value + 1);2021 setTerminalFocusRequestId((value) => value + 1);
1810 }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]);2022 void (async () => {
2023 try {
2024 await api.terminal.open({
2025 threadId: activeThreadId,
2026 terminalId,
2027 cwd: cwdForOpen,
2028 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
2029 env: projectScriptRuntimeEnv({
2030 project: { cwd: activeProject.cwd },
2031 worktreePath: activeThreadWorktreePath,
2032 }),
2033 });
2034 } catch {
2035 // Opening failed; the tab is already in the store — user can retry or close it.
2036 }
2037 })();
2038 }, [
2039 activeProject,
2040 activeKnownTerminalIds,
2041 activeThreadId,
2042 activeThreadRef,
2043 activeThreadWorktreePath,
2044 environmentId,
2045 gitCwd,
2046 hasReachedSplitLimit,
2047 storeSplitTerminal,
2048 ]);
1811 const createNewTerminal = useCallback(() => {2049 const createNewTerminal = useCallback(() => {
1812 if (!activeThreadRef) return;2050 if (!activeThreadRef || !activeThreadId || !activeProject) {
1813 const terminalId = `terminal-${randomUUID()}`;2051 return;
2052 }
2053 const cwdForOpen = gitCwd ?? activeProject.cwd;
2054 if (!cwdForOpen) {
2055 return;
2056 }
2057 const api = readEnvironmentApi(environmentId);
2058 if (!api) {
2059 return;
2060 }
2061 const terminalId = nextTerminalId(activeKnownTerminalIds);
1814 storeNewTerminal(activeThreadRef, terminalId);2062 storeNewTerminal(activeThreadRef, terminalId);
1815 setTerminalFocusRequestId((value) => value + 1);2063 setTerminalFocusRequestId((value) => value + 1);
1816 }, [activeThreadRef, storeNewTerminal]);2064 void (async () => {
2065 try {
2066 await api.terminal.open({
2067 threadId: activeThreadId,
2068 terminalId,
2069 cwd: cwdForOpen,
2070 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
2071 env: projectScriptRuntimeEnv({
2072 project: { cwd: activeProject.cwd },
2073 worktreePath: activeThreadWorktreePath,
2074 }),
2075 });
2076 } catch {
2077 // Opening failed; the tab is already in the store — user can retry or close it.
2078 }
2079 })();
2080 }, [
2081 activeProject,
2082 activeKnownTerminalIds,
2083 activeThreadId,
2084 activeThreadRef,
2085 activeThreadWorktreePath,
2086 environmentId,
2087 gitCwd,
2088 storeNewTerminal,
2089 ]);
1817 const closeTerminal = useCallback(2090 const closeTerminal = useCallback(
1818 (terminalId: string) => {2091 (terminalId: string) => {
1819 const api = readEnvironmentApi(environmentId);2092 const api = readEnvironmentApi(environmentId);
1820 if (!activeThreadId || !api) return;2093 if (!activeThreadId || !api || !activeThreadRef) return;
1821 const isFinalTerminal = terminalState.terminalIds.length <= 1;2094 const isFinalTerminal = activeKnownTerminalIds.length <= 1;
1822 const fallbackExitWrite = () =>2095 const fallbackExitWrite = () =>
1823 api.terminal2096 api.terminal
1824 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })2097 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })
...@@ -1839,18 +2112,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -1839,18 +2112,10 @@ export default function ChatView(props: ChatViewProps) {
1839 } else {2112 } else {
1840 void fallbackExitWrite();2113 void fallbackExitWrite();
1841 }2114 }
1842 if (activeThreadRef) {2115 storeCloseTerminal(activeThreadRef, terminalId);
1843 storeCloseTerminal(activeThreadRef, terminalId);
1844 }
1845 setTerminalFocusRequestId((value) => value + 1);2116 setTerminalFocusRequestId((value) => value + 1);
1846 },2117 },
1847 [2118 [activeThreadId, activeThreadRef, activeKnownTerminalIds, environmentId, storeCloseTerminal],
1848 activeThreadId,
1849 activeThreadRef,
1850 environmentId,
1851 storeCloseTerminal,
1852 terminalState.terminalIds.length,
1853 ],
1854 );2119 );
1855 const runProjectScript = useCallback(2120 const runProjectScript = useCallback(
1856 async (2121 async (
...@@ -1873,18 +2138,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -1873,18 +2138,13 @@ export default function ChatView(props: ChatViewProps) {
1873 }2138 }
1874 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;2139 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
1875 const baseTerminalId =2140 const baseTerminalId =
1876 terminalState.activeTerminalId ||2141 terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
1877 terminalState.terminalIds[0] ||2142 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
1878 DEFAULT_THREAD_TERMINAL_ID;
1879 const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId);
1880 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;2143 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
1881 const shouldCreateNewTerminal = wantsNewTerminal;2144 const shouldCreateNewTerminal = wantsNewTerminal;
1882 const targetTerminalId = shouldCreateNewTerminal
1883 ? `terminal-${randomUUID()}`
1884 : baseTerminalId;
1885 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;2145 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
18862146
1887 setTerminalLaunchContext({2147 setTerminalUiLaunchContext({
1888 threadId: activeThreadId,2148 threadId: activeThreadId,
1889 cwd: targetCwd,2149 cwd: targetCwd,
1890 worktreePath: targetWorktreePath,2150 worktreePath: targetWorktreePath,
...@@ -1893,11 +2153,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1893,11 +2153,6 @@ export default function ChatView(props: ChatViewProps) {
1893 if (!activeThreadRef) {2153 if (!activeThreadRef) {
1894 return;2154 return;
1895 }2155 }
1896 if (shouldCreateNewTerminal) {
1897 storeNewTerminal(activeThreadRef, targetTerminalId);
1898 } else {
1899 storeSetActiveTerminal(activeThreadRef, targetTerminalId);
1900 }
1901 setTerminalFocusRequestId((value) => value + 1);2156 setTerminalFocusRequestId((value) => value + 1);
19022157
1903 const runtimeEnv = projectScriptRuntimeEnv({2158 const runtimeEnv = projectScriptRuntimeEnv({
...@@ -1907,6 +2162,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -1907,6 +2162,9 @@ export default function ChatView(props: ChatViewProps) {
1907 worktreePath: targetWorktreePath,2162 worktreePath: targetWorktreePath,
1908 ...(options?.env ? { extraEnv: options.env } : {}),2163 ...(options?.env ? { extraEnv: options.env } : {}),
1909 });2164 });
2165 const targetTerminalId = shouldCreateNewTerminal
2166 ? nextTerminalId(activeKnownTerminalIds)
2167 : baseTerminalId;
1910 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal2168 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
1911 ? {2169 ? {
1912 threadId: activeThreadId,2170 threadId: activeThreadId,
...@@ -1925,6 +2183,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -1925,6 +2183,12 @@ export default function ChatView(props: ChatViewProps) {
1925 env: runtimeEnv,2183 env: runtimeEnv,
1926 };2184 };
19272185
2186 if (shouldCreateNewTerminal) {
2187 storeNewTerminal(activeThreadRef, targetTerminalId);
2188 } else {
2189 storeSetActiveTerminal(activeThreadRef, targetTerminalId);
2190 }
2191
1928 try {2192 try {
1929 await api.terminal.open(openTerminalInput);2193 await api.terminal.open(openTerminalInput);
1930 await api.terminal.write({2194 await api.terminal.write({
...@@ -1951,9 +2215,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -1951,9 +2215,9 @@ export default function ChatView(props: ChatViewProps) {
1951 storeSetActiveTerminal,2215 storeSetActiveTerminal,
1952 setLastInvokedScriptByProjectId,2216 setLastInvokedScriptByProjectId,
1953 environmentId,2217 environmentId,
1954 terminalState.activeTerminalId,2218 activeKnownTerminalIds,
1955 terminalState.runningTerminalIds,2219 runningTerminalIds,
1956 terminalState.terminalIds,2220 terminalUiState.activeTerminalId,
1957 ],2221 ],
1958 );2222 );
19592223
...@@ -2264,14 +2528,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -2264,14 +2528,14 @@ export default function ChatView(props: ChatViewProps) {
2264 }, [activeThread?.id]);2528 }, [activeThread?.id]);
22652529
2266 useEffect(() => {2530 useEffect(() => {
2267 if (!activeThread?.id || terminalState.terminalOpen) return;2531 if (!activeThread?.id || terminalUiState.terminalOpen) return;
2268 const frame = window.requestAnimationFrame(() => {2532 const frame = window.requestAnimationFrame(() => {
2269 focusComposer();2533 focusComposer();
2270 });2534 });
2271 return () => {2535 return () => {
2272 window.cancelAnimationFrame(frame);2536 window.cancelAnimationFrame(frame);
2273 };2537 };
2274 }, [activeThread?.id, focusComposer, terminalState.terminalOpen]);2538 }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]);
22752539
2276 useEffect(() => {2540 useEffect(() => {
2277 if (!activeThread?.id) return;2541 if (!activeThread?.id) return;
...@@ -2356,22 +2620,21 @@ export default function ChatView(props: ChatViewProps) {...@@ -2356,22 +2620,21 @@ export default function ChatView(props: ChatViewProps) {
23562620
2357 useEffect(() => {2621 useEffect(() => {
2358 if (!activeThreadId) {2622 if (!activeThreadId) {
2359 setTerminalLaunchContext(null);2623 setTerminalUiLaunchContext(null);
2360 storeClearTerminalLaunchContext(routeThreadRef);
2361 return;2624 return;
2362 }2625 }
2363 setTerminalLaunchContext((current) => {2626 setTerminalUiLaunchContext((current) => {
2364 if (!current) return current;2627 if (!current) return current;
2365 if (current.threadId === activeThreadId) return current;2628 if (current.threadId === activeThreadId) return current;
2366 return null;2629 return null;
2367 });2630 });
2368 }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]);2631 }, [activeThreadId]);
23692632
2370 useEffect(() => {2633 useEffect(() => {
2371 if (!activeThreadId || !activeProjectCwd) {2634 if (!activeThreadId || !activeProjectCwd) {
2372 return;2635 return;
2373 }2636 }
2374 setTerminalLaunchContext((current) => {2637 setTerminalUiLaunchContext((current) => {
2375 if (!current || current.threadId !== activeThreadId) {2638 if (!current || current.threadId !== activeThreadId) {
2376 return current;2639 return current;
2377 }2640 }
...@@ -2383,72 +2646,32 @@ export default function ChatView(props: ChatViewProps) {...@@ -2383,72 +2646,32 @@ export default function ChatView(props: ChatViewProps) {
2383 settledCwd === current.cwd &&2646 settledCwd === current.cwd &&
2384 (activeThreadWorktreePath ?? null) === current.worktreePath2647 (activeThreadWorktreePath ?? null) === current.worktreePath
2385 ) {2648 ) {
2386 if (activeThreadRef) {
2387 storeClearTerminalLaunchContext(activeThreadRef);
2388 }
2389 return null;2649 return null;
2390 }2650 }
2391 return current;2651 return current;
2392 });2652 });
2393 }, [2653 }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]);
2394 activeProjectCwd,
2395 activeThreadId,
2396 activeThreadRef,
2397 activeThreadWorktreePath,
2398 storeClearTerminalLaunchContext,
2399 ]);
24002654
2401 useEffect(() => {2655 useEffect(() => {
2402 if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) {2656 if (terminalUiState.terminalOpen) {
2403 return;2657 return;
2404 }2658 }
2405 const settledCwd = projectScriptCwd({2659 setTerminalUiLaunchContext((current) =>
2406 project: { cwd: activeProjectCwd },2660 current?.threadId === activeThreadId ? null : current,
2407 worktreePath: activeThreadWorktreePath,2661 );
2408 });2662 }, [activeThreadId, terminalUiState.terminalOpen]);
2409 if (
2410 settledCwd === storeServerTerminalLaunchContext.cwd &&
2411 (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath
2412 ) {
2413 if (activeThreadRef) {
2414 storeClearTerminalLaunchContext(activeThreadRef);
2415 }
2416 }
2417 }, [
2418 activeProjectCwd,
2419 activeThreadId,
2420 activeThreadRef,
2421 activeThreadWorktreePath,
2422 storeClearTerminalLaunchContext,
2423 storeServerTerminalLaunchContext,
2424 ]);
2425
2426 useEffect(() => {
2427 if (terminalState.terminalOpen) {
2428 return;
2429 }
2430 if (activeThreadRef) {
2431 storeClearTerminalLaunchContext(activeThreadRef);
2432 }
2433 setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current));
2434 }, [
2435 activeThreadId,
2436 activeThreadRef,
2437 storeClearTerminalLaunchContext,
2438 terminalState.terminalOpen,
2439 ]);
24402663
2441 useEffect(() => {2664 useEffect(() => {
2442 if (!activeThreadKey) return;2665 if (!activeThreadKey) return;
2443 const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false;2666 const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false;
2444 const current = Boolean(terminalState.terminalOpen);2667 const current = Boolean(terminalUiState.terminalOpen);
24452668
2446 if (!previous && current) {2669 if (!previous && current) {
2447 terminalOpenByThreadRef.current[activeThreadKey] = current;2670 terminalUiOpenByThreadRef.current[activeThreadKey] = current;
2448 setTerminalFocusRequestId((value) => value + 1);2671 setTerminalFocusRequestId((value) => value + 1);
2449 return;2672 return;
2450 } else if (previous && !current) {2673 } else if (previous && !current) {
2451 terminalOpenByThreadRef.current[activeThreadKey] = current;2674 terminalUiOpenByThreadRef.current[activeThreadKey] = current;
2452 const frame = window.requestAnimationFrame(() => {2675 const frame = window.requestAnimationFrame(() => {
2453 focusComposer();2676 focusComposer();
2454 });2677 });
...@@ -2457,8 +2680,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2457,8 +2680,8 @@ export default function ChatView(props: ChatViewProps) {
2457 };2680 };
2458 }2681 }
24592682
2460 terminalOpenByThreadRef.current[activeThreadKey] = current;2683 terminalUiOpenByThreadRef.current[activeThreadKey] = current;
2461 }, [activeThreadKey, focusComposer, terminalState.terminalOpen]);2684 }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);
24622685
2463 useEffect(() => {2686 useEffect(() => {
2464 const handler = (event: globalThis.KeyboardEvent) => {2687 const handler = (event: globalThis.KeyboardEvent) => {
...@@ -2467,7 +2690,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2467,7 +2690,7 @@ export default function ChatView(props: ChatViewProps) {
2467 }2690 }
2468 const shortcutContext = {2691 const shortcutContext = {
2469 terminalFocus: isTerminalFocused(),2692 terminalFocus: isTerminalFocused(),
2470 terminalOpen: Boolean(terminalState.terminalOpen),2693 terminalOpen: Boolean(terminalUiState.terminalOpen),
2471 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,2694 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
2472 };2695 };
24732696
...@@ -2486,7 +2709,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2486,7 +2709,7 @@ export default function ChatView(props: ChatViewProps) {
2486 if (command === "terminal.split") {2709 if (command === "terminal.split") {
2487 event.preventDefault();2710 event.preventDefault();
2488 event.stopPropagation();2711 event.stopPropagation();
2489 if (!terminalState.terminalOpen) {2712 if (!terminalUiState.terminalOpen) {
2490 setTerminalOpen(true);2713 setTerminalOpen(true);
2491 }2714 }
2492 splitTerminal();2715 splitTerminal();
...@@ -2496,15 +2719,15 @@ export default function ChatView(props: ChatViewProps) {...@@ -2496,15 +2719,15 @@ export default function ChatView(props: ChatViewProps) {
2496 if (command === "terminal.close") {2719 if (command === "terminal.close") {
2497 event.preventDefault();2720 event.preventDefault();
2498 event.stopPropagation();2721 event.stopPropagation();
2499 if (!terminalState.terminalOpen) return;2722 if (!terminalUiState.terminalOpen) return;
2500 closeTerminal(terminalState.activeTerminalId);2723 closeTerminal(terminalUiState.activeTerminalId);
2501 return;2724 return;
2502 }2725 }
25032726
2504 if (command === "terminal.new") {2727 if (command === "terminal.new") {
2505 event.preventDefault();2728 event.preventDefault();
2506 event.stopPropagation();2729 event.stopPropagation();
2507 if (!terminalState.terminalOpen) {2730 if (!terminalUiState.terminalOpen) {
2508 setTerminalOpen(true);2731 setTerminalOpen(true);
2509 }2732 }
2510 createNewTerminal();2733 createNewTerminal();
...@@ -2537,8 +2760,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2537,8 +2760,8 @@ export default function ChatView(props: ChatViewProps) {
2537 return () => window.removeEventListener("keydown", handler, true);2760 return () => window.removeEventListener("keydown", handler, true);
2538 }, [2761 }, [
2539 activeProject,2762 activeProject,
2540 terminalState.terminalOpen,2763 terminalUiState.terminalOpen,
2541 terminalState.activeTerminalId,2764 terminalUiState.activeTerminalId,
2542 activeThreadId,2765 activeThreadId,
2543 closeTerminal,2766 closeTerminal,
2544 createNewTerminal,2767 createNewTerminal,
...@@ -3527,7 +3750,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3527,7 +3750,7 @@ export default function ChatView(props: ChatViewProps) {
3527 keybindings={keybindings}3750 keybindings={keybindings}
3528 availableEditors={availableEditors}3751 availableEditors={availableEditors}
3529 terminalAvailable={activeProject !== undefined}3752 terminalAvailable={activeProject !== undefined}
3530 terminalOpen={terminalState.terminalOpen}3753 terminalOpen={terminalUiState.terminalOpen}
3531 terminalToggleShortcutLabel={terminalToggleShortcutLabel}3754 terminalToggleShortcutLabel={terminalToggleShortcutLabel}
3532 diffToggleShortcutLabel={diffPanelShortcutLabel}3755 diffToggleShortcutLabel={diffPanelShortcutLabel}
3533 gitCwd={gitCwd}3756 gitCwd={gitCwd}
...@@ -3649,7 +3872,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3649,7 +3872,7 @@ export default function ChatView(props: ChatViewProps) {
3649 resolvedTheme={resolvedTheme}3872 resolvedTheme={resolvedTheme}
3650 settings={settings}3873 settings={settings}
3651 keybindings={keybindings}3874 keybindings={keybindings}
3652 terminalOpen={Boolean(terminalState.terminalOpen)}3875 terminalOpen={Boolean(terminalUiState.terminalOpen)}
3653 gitCwd={gitCwd}3876 gitCwd={gitCwd}
3654 promptRef={promptRef}3877 promptRef={promptRef}
3655 composerImagesRef={composerImagesRef}3878 composerImagesRef={composerImagesRef}
...@@ -3745,7 +3968,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3745,7 +3968,7 @@ export default function ChatView(props: ChatViewProps) {
3745 key={mountedThreadKey}3968 key={mountedThreadKey}
3746 threadRef={mountedThreadRef}3969 threadRef={mountedThreadRef}
3747 threadId={mountedThreadRef.threadId}3970 threadId={mountedThreadRef.threadId}
3748 visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen}3971 visible={mountedThreadKey === activeThreadKey && terminalUiState.terminalOpen}
3749 launchContext={3972 launchContext={
3750 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null3973 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
3751 }3974 }
apps/web/src/components/CommandPalette.tsx+3-3
...@@ -79,7 +79,7 @@ import {...@@ -79,7 +79,7 @@ import {
79 selectSidebarThreadsAcrossEnvironments,79 selectSidebarThreadsAcrossEnvironments,
80 useStore,80 useStore,
81} from "../store";81} from "../store";
82import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";82import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
83import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";83import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
84import {84import {
85 ADDON_ICON_CLASS,85 ADDON_ICON_CLASS,
...@@ -337,9 +337,9 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -337,9 +337,9 @@ export function CommandPalette({ children }: { children: ReactNode }) {
337 select: (params) => resolveThreadRouteTarget(params),337 select: (params) => resolveThreadRouteTarget(params),
338 });338 });
339 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;339 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
340 const terminalOpen = useTerminalStateStore((state) =>340 const terminalOpen = useTerminalUiStateStore((state) =>
341 routeThreadRef341 routeThreadRef
342 ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen342 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
343 : false,343 : false,
344 );344 );
345345
apps/web/src/components/DiffPanel.tsx+19-95
...@@ -1,6 +1,4 @@...@@ -1,6 +1,4 @@
1import { parsePatchFiles } from "@pierre/diffs";1import { FileDiff, Virtualizer } from "@pierre/diffs/react";
2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
3import { useQuery } from "@tanstack/react-query";
4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";2import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { scopeThreadRef } from "@t3tools/client-runtime";3import { scopeThreadRef } from "@t3tools/client-runtime";
6import type { TurnId } from "@t3tools/contracts";4import type { TurnId } from "@t3tools/contracts";
...@@ -22,15 +20,20 @@ import {...@@ -22,15 +20,20 @@ import {
22 useState,20 useState,
23} from "react";21} from "react";
24import { openInPreferredEditor } from "../editorPreferences";22import { openInPreferredEditor } from "../editorPreferences";
25import { useGitStatus } from "~/lib/gitStatusState";23import { useCheckpointDiff } from "~/lib/checkpointDiffState";
26import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";24import { useVcsStatus } from "~/lib/vcsStatusState";
27import { cn } from "~/lib/utils";25import { cn } from "~/lib/utils";
28import { readLocalApi } from "../localApi";26import { readLocalApi } from "../localApi";
29import { resolvePathLinkTarget } from "../terminal-links";27import { resolvePathLinkTarget } from "../terminal-links";
30import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";28import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
31import { useTheme } from "../hooks/useTheme";29import { useTheme } from "../hooks/useTheme";
32import { buildPatchCacheKey } from "../lib/diffRendering";30import {
33import { resolveDiffThemeName } from "../lib/diffRendering";31 buildFileDiffRenderKey,
32 getDiffCollapseIconClassName,
33 getRenderablePatch,
34 resolveDiffThemeName,
35 resolveFileDiffPath,
36} from "../lib/diffRendering";
34import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";37import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
35import { selectProjectByRef, useStore } from "../store";38import { selectProjectByRef, useStore } from "../store";
36import { createThreadSelectorByRef } from "../storeSelectors";39import { createThreadSelectorByRef } from "../storeSelectors";
...@@ -107,76 +110,6 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -107,76 +110,6 @@ const DIFF_PANEL_UNSAFE_CSS = `
107}110}
108`;111`;
109112
110type RenderablePatch =
111 | {
112 kind: "files";
113 files: FileDiffMetadata[];
114 }
115 | {
116 kind: "raw";
117 text: string;
118 reason: string;
119 };
120
121function getRenderablePatch(
122 patch: string | undefined,
123 cacheScope = "diff-panel",
124): RenderablePatch | null {
125 if (!patch) return null;
126 const normalizedPatch = patch.trim();
127 if (normalizedPatch.length === 0) return null;
128
129 try {
130 const parsedPatches = parsePatchFiles(
131 normalizedPatch,
132 buildPatchCacheKey(normalizedPatch, cacheScope),
133 );
134 const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
135 if (files.length > 0) {
136 return { kind: "files", files };
137 }
138
139 return {
140 kind: "raw",
141 text: normalizedPatch,
142 reason: "Unsupported diff format. Showing raw patch.",
143 };
144 } catch {
145 return {
146 kind: "raw",
147 text: normalizedPatch,
148 reason: "Failed to parse patch. Showing raw patch.",
149 };
150 }
151}
152
153function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
154 const raw = fileDiff.name ?? fileDiff.prevName ?? "";
155 if (raw.startsWith("a/") || raw.startsWith("b/")) {
156 return raw.slice(2);
157 }
158 return raw;
159}
160
161function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
162 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
163}
164
165function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string {
166 switch (fileDiff.type) {
167 case "new":
168 return "text-[var(--diffs-addition-base)]";
169 case "deleted":
170 return "text-[var(--diffs-deletion-base)]";
171 case "change":
172 case "rename-pure":
173 case "rename-changed":
174 return "text-[var(--diffs-modified-base)]";
175 default:
176 return "text-muted-foreground/80";
177 }
178}
179
180interface DiffPanelProps {113interface DiffPanelProps {
181 mode?: DiffPanelMode;114 mode?: DiffPanelMode;
182}115}
...@@ -218,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -218,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
218 : undefined,151 : undefined,
219 );152 );
220 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;153 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
221 const gitStatusQuery = useGitStatus({154 const gitStatusQuery = useVcsStatus({
222 environmentId: activeThread?.environmentId ?? null,155 environmentId: activeThread?.environmentId ?? null,
223 cwd: activeCwd ?? null,156 cwd: activeCwd ?? null,
224 });157 });
...@@ -294,30 +227,21 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -294,30 +227,21 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
294 }227 }
295 return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;228 return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
296 }, [orderedTurnDiffSummaries, selectedTurn]);229 }, [orderedTurnDiffSummaries, selectedTurn]);
297 const activeCheckpointDiffQuery = useQuery(230 const activeCheckpointDiff = useCheckpointDiff(
298 checkpointDiffQueryOptions({231 {
299 environmentId: activeThread?.environmentId ?? null,232 environmentId: activeThread?.environmentId ?? null,
300 threadId: activeThreadId,233 threadId: activeThreadId,
301 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,234 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
302 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,235 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
303 ignoreWhitespace: diffIgnoreWhitespace,236 ignoreWhitespace: diffIgnoreWhitespace,
304 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,237 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
305 enabled: isGitRepo,238 },
306 }),239 { enabled: isGitRepo },
307 );240 );
308 const selectedTurnCheckpointDiff = selectedTurn241 const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiff.data?.diff : undefined;
309 ? activeCheckpointDiffQuery.data?.diff242 const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiff.data?.diff;
310 : undefined;243 const isLoadingCheckpointDiff = activeCheckpointDiff.isPending;
311 const conversationCheckpointDiff = selectedTurn244 const checkpointDiffError = activeCheckpointDiff.error;
312 ? undefined
313 : activeCheckpointDiffQuery.data?.diff;
314 const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading;
315 const checkpointDiffError =
316 activeCheckpointDiffQuery.error instanceof Error
317 ? activeCheckpointDiffQuery.error.message
318 : activeCheckpointDiffQuery.error
319 ? "Failed to load checkpoint diff."
320 : null;
321245
322 const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff;246 const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff;
323 const hasResolvedPatch = typeof selectedPatch === "string";247 const hasResolvedPatch = typeof selectedPatch === "string";
apps/web/src/components/GitActionsControl.browser.tsx+41-58
...@@ -26,9 +26,9 @@ const {...@@ -26,9 +26,9 @@ const {
26 activeRunStackedActionDeferredRef,26 activeRunStackedActionDeferredRef,
27 activeDraftThreadRef,27 activeDraftThreadRef,
28 hasServerThreadRef,28 hasServerThreadRef,
29 invalidateGitQueriesSpy,29 invalidateSourceControlStateSpy,
30 refreshGitStatusSpy,30 refreshVcsStatusSpy,
31 runStackedActionMutateAsyncSpy,31 runStackedActionSpy,
32 setDraftThreadContextSpy,32 setDraftThreadContextSpy,
33 setThreadBranchSpy,33 setThreadBranchSpy,
34 toastAddSpy,34 toastAddSpy,
...@@ -39,9 +39,9 @@ const {...@@ -39,9 +39,9 @@ const {
39 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },39 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
40 activeDraftThreadRef: { current: null as unknown },40 activeDraftThreadRef: { current: null as unknown },
41 hasServerThreadRef: { current: true },41 hasServerThreadRef: { current: true },
42 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),42 invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()),
43 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),43 refreshVcsStatusSpy: vi.fn(() => Promise.resolve(null)),
44 runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),44 runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
45 setDraftThreadContextSpy: vi.fn(),45 setDraftThreadContextSpy: vi.fn(),
46 setThreadBranchSpy: vi.fn(),46 setThreadBranchSpy: vi.fn(),
47 toastAddSpy: vi.fn(() => "toast-1"),47 toastAddSpy: vi.fn(() => "toast-1"),
...@@ -50,39 +50,6 @@ const {...@@ -50,39 +50,6 @@ const {
50 toastUpdateSpy: vi.fn(),50 toastUpdateSpy: vi.fn(),
51}));51}));
5252
53vi.mock("@tanstack/react-query", async () => {
54 const actual =
55 await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
56
57 return {
58 ...actual,
59 useIsMutating: vi.fn(() => 0),
60 useMutation: vi.fn((options: { __kind?: string }) => {
61 if (options.__kind === "run-stacked-action") {
62 return {
63 mutateAsync: runStackedActionMutateAsyncSpy,
64 isPending: false,
65 };
66 }
67
68 if (options.__kind === "pull") {
69 return {
70 mutateAsync: vi.fn(),
71 isPending: false,
72 };
73 }
74
75 return {
76 mutate: vi.fn(),
77 mutateAsync: vi.fn(),
78 isPending: false,
79 };
80 }),
81 useQuery: vi.fn(() => ({ data: null, error: null })),
82 useQueryClient: vi.fn(() => ({})),
83 };
84});
85
86vi.mock("~/components/ui/toast", () => ({53vi.mock("~/components/ui/toast", () => ({
87 toastManager: {54 toastManager: {
88 add: toastAddSpy,55 add: toastAddSpy,
...@@ -97,23 +64,39 @@ vi.mock("~/editorPreferences", () => ({...@@ -97,23 +64,39 @@ vi.mock("~/editorPreferences", () => ({
97 openInPreferredEditor: vi.fn(),64 openInPreferredEditor: vi.fn(),
98}));65}));
9966
100vi.mock("~/lib/gitReactQuery", () => ({67vi.mock("~/lib/sourceControlActions", () => ({
101 gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })),68 invalidateSourceControlState: invalidateSourceControlStateSpy,
102 gitMutationKeys: {69 useGitStackedAction: vi.fn(() => ({
103 publishRepository: vi.fn(() => ["publish-repository"]),70 error: null,
104 pull: vi.fn(() => ["pull"]),71 isPending: false,
105 runStackedAction: vi.fn(() => ["run-stacked-action"]),72 resetError: vi.fn(),
106 },73 run: runStackedActionSpy,
107 gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })),74 })),
108 gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })),75 useSourceControlActionRunning: vi.fn(() => false),
109 invalidateGitQueries: invalidateGitQueriesSpy,76 useSourceControlPublishRepositoryAction: vi.fn(() => ({
110 sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })),77 error: null,
78 isPending: false,
79 resetError: vi.fn(),
80 run: vi.fn(),
81 })),
82 useVcsInitAction: vi.fn(() => ({
83 error: null,
84 isPending: false,
85 resetError: vi.fn(),
86 run: vi.fn(),
87 })),
88 useVcsPullAction: vi.fn(() => ({
89 error: null,
90 isPending: false,
91 resetError: vi.fn(),
92 run: vi.fn(),
93 })),
111}));94}));
11295
113vi.mock("~/lib/gitStatusState", () => ({96vi.mock("~/lib/vcsStatusState", () => ({
114 refreshGitStatus: refreshGitStatusSpy,97 refreshVcsStatus: refreshVcsStatusSpy,
115 resetGitStatusStateForTests: () => undefined,98 resetVcsStatusStateForTests: () => undefined,
116 useGitStatus: vi.fn(() => ({99 useVcsStatus: vi.fn(() => ({
117 data: {100 data: {
118 isRepo: true,101 isRepo: true,
119 sourceControlProvider: {102 sourceControlProvider: {
...@@ -379,14 +362,14 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -379,14 +362,14 @@ describe("GitActionsControl thread-scoped progress toast", () => {
379 visibilityState = "visible";362 visibilityState = "visible";
380 document.dispatchEvent(new Event("visibilitychange"));363 document.dispatchEvent(new Event("visibilitychange"));
381364
382 expect(refreshGitStatusSpy).not.toHaveBeenCalled();365 expect(refreshVcsStatusSpy).not.toHaveBeenCalled();
383366
384 await vi.advanceTimersByTimeAsync(249);367 await vi.advanceTimersByTimeAsync(249);
385 expect(refreshGitStatusSpy).not.toHaveBeenCalled();368 expect(refreshVcsStatusSpy).not.toHaveBeenCalled();
386369
387 await vi.advanceTimersByTimeAsync(1);370 await vi.advanceTimersByTimeAsync(1);
388 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);371 expect(refreshVcsStatusSpy).toHaveBeenCalledTimes(1);
389 expect(refreshGitStatusSpy).toHaveBeenCalledWith({372 expect(refreshVcsStatusSpy).toHaveBeenCalledWith({
390 environmentId: ENVIRONMENT_A,373 environmentId: ENVIRONMENT_A,
391 cwd: GIT_CWD,374 cwd: GIT_CWD,
392 });375 });
apps/web/src/components/GitActionsControl.tsx+65-79
...@@ -10,7 +10,6 @@ import type {...@@ -10,7 +10,6 @@ import type {
10 SourceControlRepositoryVisibility,10 SourceControlRepositoryVisibility,
11 VcsStatusResult,11 VcsStatusResult,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
14import { useNavigate } from "@tanstack/react-router";13import { useNavigate } from "@tanstack/react-router";
15import * as Option from "effect/Option";14import * as Option from "effect/Option";
16import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";15import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
...@@ -65,13 +64,13 @@ import { stackedThreadToast, toastManager, type ThreadToastData } from "~/compon...@@ -65,13 +64,13 @@ import { stackedThreadToast, toastManager, type ThreadToastData } from "~/compon
65import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";64import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
66import { openInPreferredEditor } from "~/editorPreferences";65import { openInPreferredEditor } from "~/editorPreferences";
67import {66import {
68 gitInitMutationOptions,67 useGitStackedAction,
69 gitMutationKeys,68 useSourceControlActionRunning,
70 gitPullMutationOptions,69 useSourceControlPublishRepositoryAction,
71 gitRunStackedActionMutationOptions,70 useVcsInitAction,
72 sourceControlPublishRepositoryMutationOptions,71 useVcsPullAction,
73} from "~/lib/gitReactQuery";72} from "~/lib/sourceControlActions";
74import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";73import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState";
75import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";74import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";
76import { newCommandId, randomUUID } from "~/lib/utils";75import { newCommandId, randomUUID } from "~/lib/utils";
77import { resolvePathLinkTarget } from "~/terminal-links";76import { resolvePathLinkTarget } from "~/terminal-links";
...@@ -128,6 +127,7 @@ interface RunGitActionWithToastInput {...@@ -128,6 +127,7 @@ interface RunGitActionWithToastInput {
128}127}
129128
130const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;129const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;
130const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
131131
132const PUBLISH_PROVIDER_OPTIONS = [132const PUBLISH_PROVIDER_OPTIONS = [
133 {133 {
...@@ -346,7 +346,6 @@ interface PublishRepositoryDialogProps {...@@ -346,7 +346,6 @@ interface PublishRepositoryDialogProps {
346}346}
347347
348function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {348function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
349 const queryClient = useQueryClient();
350 const navigate = useNavigate();349 const navigate = useNavigate();
351 const sourceControlDiscovery = useSourceControlDiscovery();350 const sourceControlDiscovery = useSourceControlDiscovery();
352 const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github");351 const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github");
...@@ -362,13 +361,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -362,13 +361,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
362 null,361 null,
363 );362 );
364 const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false);363 const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false);
365 const publishRepositoryMutation = useMutation(364 const sourceControlScope = useMemo(
366 sourceControlPublishRepositoryMutationOptions({365 () => ({
367 environmentId: props.environmentId,366 environmentId: props.environmentId,
368 cwd: props.gitCwd,367 cwd: props.gitCwd,
369 queryClient,
370 }),368 }),
369 [props.environmentId, props.gitCwd],
371 );370 );
371 const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);
372 const publishAccountByProvider = useMemo(() => {372 const publishAccountByProvider = useMemo(() => {
373 const accounts: Record<PublishProviderKind, string | null> = {373 const accounts: Record<PublishProviderKind, string | null> = {
374 github: null,374 github: null,
...@@ -435,13 +435,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -435,13 +435,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
435435
436 const canSubmitPublishRepository = useMemo(() => {436 const canSubmitPublishRepository = useMemo(() => {
437 if (!selectedPublishProviderReadiness.ready) return false;437 if (!selectedPublishProviderReadiness.ready) return false;
438 if (publishRepositoryMutation.isPending) return false;438 if (publishRepositoryAction.isPending) return false;
439 const repositoryParts = publishRepository.trim().split("/");439 const repositoryParts = publishRepository.trim().split("/");
440 const owner = repositoryParts[0]?.trim() ?? "";440 const owner = repositoryParts[0]?.trim() ?? "";
441 const rest = repositoryParts.slice(1);441 const rest = repositoryParts.slice(1);
442 const name = rest.join("/").trim();442 const name = rest.join("/").trim();
443 return owner.length > 0 && name.length > 0;443 return owner.length > 0 && name.length > 0;
444 }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]);444 }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]);
445445
446 useEffect(() => {446 useEffect(() => {
447 if (!props.open) {447 if (!props.open) {
...@@ -465,8 +465,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -465,8 +465,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
465465
466 setPublishError(null);466 setPublishError(null);
467467
468 void publishRepositoryMutation468 void publishRepositoryAction
469 .mutateAsync({469 .run({
470 provider: publishProvider,470 provider: publishProvider,
471 repository: publishRepository.trim(),471 repository: publishRepository.trim(),
472 visibility: publishVisibility,472 visibility: publishVisibility,
...@@ -478,7 +478,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -478,7 +478,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
478 setPublishResult(result);478 setPublishResult(result);
479 setPublishWizardStep(2);479 setPublishWizardStep(2);
480 });480 });
481 void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch(481 void refreshVcsStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch(
482 () => undefined,482 () => undefined,
483 );483 );
484 })484 })
...@@ -493,7 +493,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -493,7 +493,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
493 publishProvider,493 publishProvider,
494 publishRemoteName,494 publishRemoteName,
495 publishRepository,495 publishRepository,
496 publishRepositoryMutation,496 publishRepositoryAction,
497 publishVisibility,497 publishVisibility,
498 ]);498 ]);
499499
...@@ -689,7 +689,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -689,7 +689,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
689 }689 }
690 }}690 }}
691 placeholder={publishPathPlaceholder}691 placeholder={publishPathPlaceholder}
692 disabled={publishRepositoryMutation.isPending}692 disabled={publishRepositoryAction.isPending}
693 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"693 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"
694 />694 />
695 </div>695 </div>
...@@ -708,7 +708,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -708,7 +708,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
708 setPublishVisibility(value as SourceControlRepositoryVisibility)708 setPublishVisibility(value as SourceControlRepositoryVisibility)
709 }709 }
710 aria-labelledby="publish-visibility-cards-label"710 aria-labelledby="publish-visibility-cards-label"
711 disabled={publishRepositoryMutation.isPending}711 disabled={publishRepositoryAction.isPending}
712 className="grid grid-cols-2 gap-2.5"712 className="grid grid-cols-2 gap-2.5"
713 >713 >
714 {[714 {[
...@@ -780,7 +780,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -780,7 +780,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
780 value={publishRemoteName}780 value={publishRemoteName}
781 onChange={(event) => setPublishRemoteName(event.target.value)}781 onChange={(event) => setPublishRemoteName(event.target.value)}
782 placeholder="origin"782 placeholder="origin"
783 disabled={publishRepositoryMutation.isPending}783 disabled={publishRepositoryAction.isPending}
784 />784 />
785 </label>785 </label>
786 <div className="space-y-1.5">786 <div className="space-y-1.5">
...@@ -796,7 +796,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -796,7 +796,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
796 setPublishProtocol(value as SourceControlCloneProtocol)796 setPublishProtocol(value as SourceControlCloneProtocol)
797 }797 }
798 aria-labelledby="publish-protocol-label"798 aria-labelledby="publish-protocol-label"
799 disabled={publishRepositoryMutation.isPending}799 disabled={publishRepositoryAction.isPending}
800 className="grid grid-cols-2 gap-2"800 className="grid grid-cols-2 gap-2"
801 >801 >
802 {(["ssh", "https"] as const).map((value) => {802 {(["ssh", "https"] as const).map((value) => {
...@@ -823,7 +823,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -823,7 +823,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
823 ) : null}823 ) : null}
824 </div>824 </div>
825825
826 {publishRepositoryMutation.isPending ? (826 {publishRepositoryAction.isPending ? (
827 <div827 <div
828 role="status"828 role="status"
829 aria-live="polite"829 aria-live="polite"
...@@ -833,7 +833,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -833,7 +833,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
833 Publishing repository to {publishProviderLabel}...833 Publishing repository to {publishProviderLabel}...
834 </div>834 </div>
835 ) : null}835 ) : null}
836 {publishError && !publishRepositoryMutation.isPending ? (836 {publishError && !publishRepositoryAction.isPending ? (
837 <div837 <div
838 role="alert"838 role="alert"
839 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"839 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
...@@ -901,7 +901,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -901,7 +901,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
901 <Button901 <Button
902 variant="outline"902 variant="outline"
903 size="sm"903 size="sm"
904 disabled={publishRepositoryMutation.isPending}904 disabled={publishRepositoryAction.isPending}
905 onClick={() => {905 onClick={() => {
906 if (publishWizardStep === 0) {906 if (publishWizardStep === 0) {
907 handleOpenChange(false);907 handleOpenChange(false);
...@@ -926,7 +926,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -926,7 +926,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
926 disabled={!canSubmitPublishRepository}926 disabled={!canSubmitPublishRepository}
927 onClick={submitPublishRepository}927 onClick={submitPublishRepository}
928 >928 >
929 {publishRepositoryMutation.isPending ? (929 {publishRepositoryAction.isPending ? (
930 <>930 <>
931 <Spinner className="size-3.5" aria-hidden />931 <Spinner className="size-3.5" aria-hidden />
932 Publishing...932 Publishing...
...@@ -969,7 +969,6 @@ export default function GitActionsControl({...@@ -969,7 +969,6 @@ export default function GitActionsControl({
969 );969 );
970 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);970 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
971 const setThreadBranch = useStore((store) => store.setThreadBranch);971 const setThreadBranch = useStore((store) => store.setThreadBranch);
972 const queryClient = useQueryClient();
973 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);972 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
974 const [dialogCommitMessage, setDialogCommitMessage] = useState("");973 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
975 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());974 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
...@@ -978,6 +977,10 @@ export default function GitActionsControl({...@@ -978,6 +977,10 @@ export default function GitActionsControl({
978 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =977 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
979 useState<PendingDefaultBranchAction | null>(null);978 useState<PendingDefaultBranchAction | null>(null);
980 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);979 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);
980 const sourceControlScope = useMemo(
981 () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }),
982 [activeEnvironmentId, gitCwd],
983 );
981 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;984 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;
982985
983 const updateActiveProgressToast = useCallback(() => {986 const updateActiveProgressToast = useCallback(() => {
...@@ -1054,7 +1057,7 @@ export default function GitActionsControl({...@@ -1054,7 +1057,7 @@ export default function GitActionsControl({
1054 [persistThreadBranchSync],1057 [persistThreadBranchSync],
1055 );1058 );
10561059
1057 const { data: gitStatus = null, error: gitStatusError } = useGitStatus({1060 const { data: gitStatus = null, error: gitStatusError } = useVcsStatus({
1058 environmentId: activeEnvironmentId,1061 environmentId: activeEnvironmentId,
1059 cwd: gitCwd,1062 cwd: gitCwd,
1060 });1063 });
...@@ -1074,32 +1077,13 @@ export default function GitActionsControl({...@@ -1074,32 +1077,13 @@ export default function GitActionsControl({
1074 const allSelected = excludedFiles.size === 0;1077 const allSelected = excludedFiles.size === 0;
1075 const noneSelected = selectedFiles.length === 0;1078 const noneSelected = selectedFiles.length === 0;
10761079
1077 const initMutation = useMutation(1080 const initAction = useVcsInitAction(sourceControlScope);
1078 gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),1081 const runImmediateGitAction = useGitStackedAction(sourceControlScope);
1079 );1082 const pullAction = useVcsPullAction(sourceControlScope);
10801083 const isGitActionRunning = useSourceControlActionRunning(
1081 const runImmediateGitActionMutation = useMutation(1084 sourceControlScope,
1082 gitRunStackedActionMutationOptions({1085 RUNNING_SOURCE_CONTROL_ACTIONS,
1083 environmentId: activeEnvironmentId,
1084 cwd: gitCwd,
1085 queryClient,
1086 }),
1087 );1086 );
1088 const pullMutation = useMutation(
1089 gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
1090 );
1091
1092 const isRunStackedActionRunning =
1093 useIsMutating({
1094 mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd),
1095 }) > 0;
1096 const isPullRunning =
1097 useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;
1098 const isPublishRunning =
1099 useIsMutating({
1100 mutationKey: gitMutationKeys.publishRepository(activeEnvironmentId, gitCwd),
1101 }) > 0;
1102 const isGitActionRunning = isRunStackedActionRunning || isPullRunning || isPublishRunning;
1103 const isSelectingWorktreeBase =1087 const isSelectingWorktreeBase =
1104 !activeServerThread &&1088 !activeServerThread &&
1105 activeDraftThread?.envMode === "worktree" &&1089 activeDraftThread?.envMode === "worktree" &&
...@@ -1178,7 +1162,7 @@ export default function GitActionsControl({...@@ -1178,7 +1162,7 @@ export default function GitActionsControl({
1178 }1162 }
1179 refreshTimeout = window.setTimeout(() => {1163 refreshTimeout = window.setTimeout(() => {
1180 refreshTimeout = null;1164 refreshTimeout = null;
1181 void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch(1165 void refreshVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch(
1182 () => undefined,1166 () => undefined,
1183 );1167 );
1184 }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);1168 }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);
...@@ -1376,7 +1360,7 @@ export default function GitActionsControl({...@@ -1376,7 +1360,7 @@ export default function GitActionsControl({
1376 updateActiveProgressToast();1360 updateActiveProgressToast();
1377 };1361 };
13781362
1379 const promise = runImmediateGitActionMutation.mutateAsync({1363 const promise = runImmediateGitAction.run({
1380 actionId,1364 actionId,
1381 action,1365 action,
1382 ...(commitMessage ? { commitMessage } : {}),1366 ...(commitMessage ? { commitMessage } : {}),
...@@ -1516,26 +1500,26 @@ export default function GitActionsControl({...@@ -1516,26 +1500,26 @@ export default function GitActionsControl({
1516 return;1500 return;
1517 }1501 }
1518 if (quickAction.kind === "run_pull") {1502 if (quickAction.kind === "run_pull") {
1519 const promise = pullMutation.mutateAsync();1503 const promise = pullAction.run();
1520 void toastManager.promise<1504 void toastManager.promise<Awaited<ReturnType<typeof pullAction.run>>, ThreadToastData>(
1521 Awaited<ReturnType<typeof pullMutation.mutateAsync>>,1505 promise,
1522 ThreadToastData1506 {
1523 >(promise, {1507 loading: { title: "Pulling...", data: threadToastData },
1524 loading: { title: "Pulling...", data: threadToastData },1508 success: (result) => ({
1525 success: (result) => ({1509 title: result.status === "pulled" ? "Pulled" : "Already up to date",
1526 title: result.status === "pulled" ? "Pulled" : "Already up to date",1510 description:
1527 description:1511 result.status === "pulled"
1528 result.status === "pulled"1512 ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}`
1529 ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}`1513 : `${result.refName} is already synchronized.`,
1530 : `${result.refName} is already synchronized.`,1514 data: threadToastData,
1531 data: threadToastData,1515 }),
1532 }),1516 error: (err) => ({
1533 error: (err) => ({1517 title: "Pull failed",
1534 title: "Pull failed",1518 description: err instanceof Error ? err.message : "An error occurred.",
1535 description: err instanceof Error ? err.message : "An error occurred.",1519 data: threadToastData,
1536 data: threadToastData,1520 }),
1537 }),1521 },
1538 });1522 );
1539 void promise.catch(() => undefined);1523 void promise.catch(() => undefined);
1540 return;1524 return;
1541 }1525 }
...@@ -1622,10 +1606,12 @@ export default function GitActionsControl({...@@ -1622,10 +1606,12 @@ export default function GitActionsControl({
1622 <Button1606 <Button
1623 variant="outline"1607 variant="outline"
1624 size="xs"1608 size="xs"
1625 disabled={initMutation.isPending}1609 disabled={initAction.isPending}
1626 onClick={() => initMutation.mutate()}1610 onClick={() => {
1611 void initAction.run();
1612 }}
1627 >1613 >
1628 {initMutation.isPending ? "Initializing..." : "Initialize Git"}1614 {initAction.isPending ? "Initializing..." : "Initialize Git"}
1629 </Button>1615 </Button>
1630 ) : (1616 ) : (
1631 <Group aria-label="Git actions" className="shrink-0">1617 <Group aria-label="Git actions" className="shrink-0">
...@@ -1671,7 +1657,7 @@ export default function GitActionsControl({...@@ -1671,7 +1657,7 @@ export default function GitActionsControl({
1671 <Menu1657 <Menu
1672 onOpenChange={(open) => {1658 onOpenChange={(open) => {
1673 if (open) {1659 if (open) {
1674 void refreshGitStatus({1660 void refreshVcsStatus({
1675 environmentId: activeEnvironmentId,1661 environmentId: activeEnvironmentId,
1676 cwd: gitCwd,1662 cwd: gitCwd,
1677 }).catch(() => undefined);1663 }).catch(() => undefined);
apps/web/src/components/KeybindingsToast.browser.tsx+32-6
...@@ -33,12 +33,38 @@ import { useStore } from "../store";...@@ -33,12 +33,38 @@ import { useStore } from "../store";
33import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";33import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
34import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";34import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";
3535
36vi.mock("../lib/gitStatusState", () => ({36vi.mock("../lib/vcsStatusState", () => {
37 useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }),37 const status = {
38 useGitStatuses: () => new Map(),38 data: {
39 refreshGitStatus: () => Promise.resolve(null),39 isRepo: true,
40 resetGitStatusStateForTests: () => undefined,40 sourceControlProvider: {
41}));41 kind: "github",
42 name: "GitHub",
43 baseUrl: "https://github.com",
44 },
45 hasPrimaryRemote: true,
46 isDefaultRef: true,
47 refName: "main",
48 hasWorkingTreeChanges: false,
49 workingTree: { files: [], insertions: 0, deletions: 0 },
50 hasUpstream: true,
51 aheadCount: 0,
52 behindCount: 0,
53 pr: null,
54 },
55 error: null,
56 cause: null,
57 isPending: false,
58 };
59
60 return {
61 getVcsStatusSnapshot: () => status,
62 useVcsStatus: () => status,
63 useVcsStatuses: () => new Map(),
64 refreshVcsStatus: () => Promise.resolve(null),
65 resetVcsStatusStateForTests: () => undefined,
66 };
67});
4268
43const THREAD_ID = "thread-kb-toast-test" as ThreadId;69const THREAD_ID = "thread-kb-toast-test" as ThreadId;
44const PROJECT_ID = "project-1" as ProjectId;70const PROJECT_ID = "project-1" as ProjectId;
apps/web/src/components/PullRequestThreadDialog.tsx+38-42
...@@ -1,13 +1,13 @@...@@ -1,13 +1,13 @@
1import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useDebouncedValue } from "@tanstack/react-pacer";2import { useDebouncedValue } from "@tanstack/react-pacer";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
54
6import {5import {
7 gitPreparePullRequestThreadMutationOptions,6 readCachedPullRequestResolution,
8 gitResolvePullRequestQueryOptions,7 usePreparePullRequestThreadAction,
9} from "~/lib/gitReactQuery";8 usePullRequestResolution,
10import { useGitStatus } from "~/lib/gitStatusState";9} from "~/lib/sourceControlActions";
10import { useVcsStatus } from "~/lib/vcsStatusState";
11import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
12import { parsePullRequestReference } from "~/pullRequestReference";12import { parsePullRequestReference } from "~/pullRequestReference";
13import { getSourceControlPresentation } from "~/sourceControlPresentation";13import { getSourceControlPresentation } from "~/sourceControlPresentation";
...@@ -43,7 +43,6 @@ export function PullRequestThreadDialog({...@@ -43,7 +43,6 @@ export function PullRequestThreadDialog({
43 onOpenChange,43 onOpenChange,
44 onPrepared,44 onPrepared,
45}: PullRequestThreadDialogProps) {45}: PullRequestThreadDialogProps) {
46 const queryClient = useQueryClient();
47 const referenceInputRef = useRef<HTMLInputElement>(null);46 const referenceInputRef = useRef<HTMLInputElement>(null);
48 const [reference, setReference] = useState(initialReference ?? "");47 const [reference, setReference] = useState(initialReference ?? "");
49 const [referenceDirty, setReferenceDirty] = useState(false);48 const [referenceDirty, setReferenceDirty] = useState(false);
...@@ -53,7 +52,7 @@ export function PullRequestThreadDialog({...@@ -53,7 +52,7 @@ export function PullRequestThreadDialog({
53 { wait: 450 },52 { wait: 450 },
54 (debouncerState) => ({ isPending: debouncerState.isPending }),53 (debouncerState) => ({ isPending: debouncerState.isPending }),
55 );54 );
56 const { data: gitStatus = null } = useGitStatus({ environmentId, cwd });55 const { data: gitStatus = null } = useVcsStatus({ environmentId, cwd });
57 const sourceControlPresentation = useMemo(56 const sourceControlPresentation = useMemo(
58 () => getSourceControlPresentation(gitStatus?.sourceControlProvider),57 () => getSourceControlPresentation(gitStatus?.sourceControlProvider),
59 [gitStatus?.sourceControlProvider],58 [gitStatus?.sourceControlProvider],
...@@ -81,33 +80,30 @@ export function PullRequestThreadDialog({...@@ -81,33 +80,30 @@ export function PullRequestThreadDialog({
8180
82 const parsedReference = parsePullRequestReference(reference);81 const parsedReference = parsePullRequestReference(reference);
83 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);82 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
84 const resolvePullRequestQuery = useQuery(83 const sourceControlScope = useMemo(
85 gitResolvePullRequestQueryOptions({84 () => ({
86 environmentId,85 environmentId,
87 cwd,86 cwd,
88 reference: open ? parsedDebouncedReference : null,
89 }),87 }),
88 [cwd, environmentId],
90 );89 );
90 const pullRequestResolution = usePullRequestResolution({
91 ...sourceControlScope,
92 reference: open ? parsedDebouncedReference : null,
93 });
91 const cachedPullRequest = useMemo(() => {94 const cachedPullRequest = useMemo(() => {
92 if (!cwd || !parsedReference) {95 return (
93 return null;96 readCachedPullRequestResolution({
94 }97 ...sourceControlScope,
95 const cached = queryClient.getQueryData<GitResolvePullRequestResult>([98 reference: parsedReference,
96 "git",99 })?.pullRequest ?? null
97 "pull-request",100 );
98 environmentId,101 }, [parsedReference, sourceControlScope]);
99 cwd,102 const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope);
100 parsedReference,
101 ]);
102 return cached?.pullRequest ?? null;
103 }, [cwd, environmentId, parsedReference, queryClient]);
104 const preparePullRequestThreadMutation = useMutation(
105 gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }),
106 );
107103
108 const liveResolvedPullRequest =104 const liveResolvedPullRequest =
109 parsedReference !== null && parsedReference === parsedDebouncedReference105 parsedReference !== null && parsedReference === parsedDebouncedReference
110 ? (resolvePullRequestQuery.data?.pullRequest ?? null)106 ? (pullRequestResolution.data?.pullRequest ?? null)
111 : null;107 : null;
112 const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest;108 const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest;
113 const isResolving =109 const isResolving =
...@@ -116,8 +112,8 @@ export function PullRequestThreadDialog({...@@ -116,8 +112,8 @@ export function PullRequestThreadDialog({
116 resolvedPullRequest === null &&112 resolvedPullRequest === null &&
117 (referenceDebouncer.state.isPending ||113 (referenceDebouncer.state.isPending ||
118 parsedReference !== parsedDebouncedReference ||114 parsedReference !== parsedDebouncedReference ||
119 resolvePullRequestQuery.isPending ||115 pullRequestResolution.isPending ||
120 resolvePullRequestQuery.isFetching);116 pullRequestResolution.isFetching);
121 const statusTone = useMemo(() => {117 const statusTone = useMemo(() => {
122 switch (resolvedPullRequest?.state) {118 switch (resolvedPullRequest?.state) {
123 case "merged":119 case "merged":
...@@ -142,7 +138,7 @@ export function PullRequestThreadDialog({...@@ -142,7 +138,7 @@ export function PullRequestThreadDialog({
142 }138 }
143 setPreparingMode(mode);139 setPreparingMode(mode);
144 try {140 try {
145 const result = await preparePullRequestThreadMutation.mutateAsync({141 const result = await preparePullRequestThreadAction.run({
146 reference: parsedReference,142 reference: parsedReference,
147 mode,143 mode,
148 ...(mode === "worktree" ? { threadId } : {}),144 ...(mode === "worktree" ? { threadId } : {}),
...@@ -161,7 +157,7 @@ export function PullRequestThreadDialog({...@@ -161,7 +157,7 @@ export function PullRequestThreadDialog({
161 onOpenChange,157 onOpenChange,
162 onPrepared,158 onPrepared,
163 parsedReference,159 parsedReference,
164 preparePullRequestThreadMutation,160 preparePullRequestThreadAction,
165 resolvedPullRequest,161 resolvedPullRequest,
166 threadId,162 threadId,
167 ],163 ],
...@@ -176,13 +172,13 @@ export function PullRequestThreadDialog({...@@ -176,13 +172,13 @@ export function PullRequestThreadDialog({
176 : null;172 : null;
177 const errorMessage =173 const errorMessage =
178 validationMessage ??174 validationMessage ??
179 (resolvedPullRequest === null && resolvePullRequestQuery.isError175 (resolvedPullRequest === null && pullRequestResolution.error
180 ? resolvePullRequestQuery.error instanceof Error176 ? pullRequestResolution.error instanceof Error
181 ? resolvePullRequestQuery.error.message177 ? pullRequestResolution.error.message
182 : `Failed to resolve ${terminology.singular}.`178 : `Failed to resolve ${terminology.singular}.`
183 : preparePullRequestThreadMutation.error instanceof Error179 : preparePullRequestThreadAction.error instanceof Error
184 ? preparePullRequestThreadMutation.error.message180 ? preparePullRequestThreadAction.error.message
185 : preparePullRequestThreadMutation.error181 : preparePullRequestThreadAction.error
186 ? `Failed to prepare ${terminology.singular} thread.`182 ? `Failed to prepare ${terminology.singular} thread.`
187 : null);183 : null);
188184
...@@ -190,7 +186,7 @@ export function PullRequestThreadDialog({...@@ -190,7 +186,7 @@ export function PullRequestThreadDialog({
190 <Dialog186 <Dialog
191 open={open}187 open={open}
192 onOpenChange={(nextOpen) => {188 onOpenChange={(nextOpen) => {
193 if (!preparePullRequestThreadMutation.isPending) {189 if (!preparePullRequestThreadAction.isPending) {
194 onOpenChange(nextOpen);190 onOpenChange(nextOpen);
195 }191 }
196 }}192 }}
...@@ -224,7 +220,7 @@ export function PullRequestThreadDialog({...@@ -224,7 +220,7 @@ export function PullRequestThreadDialog({
224 return;220 return;
225 }221 }
226 event.preventDefault();222 event.preventDefault();
227 if (!isResolving && !preparePullRequestThreadMutation.isPending) {223 if (!isResolving && !preparePullRequestThreadAction.isPending) {
228 void handleConfirm("local");224 void handleConfirm("local");
229 }225 }
230 }}226 }}
...@@ -263,7 +259,7 @@ export function PullRequestThreadDialog({...@@ -263,7 +259,7 @@ export function PullRequestThreadDialog({
263 variant="outline"259 variant="outline"
264 size="sm"260 size="sm"
265 onClick={() => onOpenChange(false)}261 onClick={() => onOpenChange(false)}
266 disabled={preparePullRequestThreadMutation.isPending}262 disabled={preparePullRequestThreadAction.isPending}
267 >263 >
268 Cancel264 Cancel
269 </Button>265 </Button>
...@@ -278,7 +274,7 @@ export function PullRequestThreadDialog({...@@ -278,7 +274,7 @@ export function PullRequestThreadDialog({
278 !cwd ||274 !cwd ||
279 !resolvedPullRequest ||275 !resolvedPullRequest ||
280 isResolving ||276 isResolving ||
281 preparePullRequestThreadMutation.isPending277 preparePullRequestThreadAction.isPending
282 }278 }
283 >279 >
284 {preparingMode === "local" ? "Preparing local..." : "Local"}280 {preparingMode === "local" ? "Preparing local..." : "Local"}
...@@ -293,7 +289,7 @@ export function PullRequestThreadDialog({...@@ -293,7 +289,7 @@ export function PullRequestThreadDialog({
293 !cwd ||289 !cwd ||
294 !resolvedPullRequest ||290 !resolvedPullRequest ||
295 isResolving ||291 isResolving ||
296 preparePullRequestThreadMutation.isPending292 preparePullRequestThreadAction.isPending
297 }293 }
298 >294 >
299 {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"}295 {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"}
apps/web/src/components/Sidebar.tsx+12-11
...@@ -73,7 +73,8 @@ import {...@@ -73,7 +73,8 @@ import {
73 selectThreadByRef,73 selectThreadByRef,
74 useStore,74 useStore,
75} from "../store";75} from "../store";
76import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";76import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
77import { useThreadRunningTerminalIds } from "../terminalSessionState";
77import { useUiStateStore } from "../uiStateStore";78import { useUiStateStore } from "../uiStateStore";
78import {79import {
79 resolveShortcutCommand,80 resolveShortcutCommand,
...@@ -85,7 +86,7 @@ import {...@@ -85,7 +86,7 @@ import {
85} from "../keybindings";86} from "../keybindings";
86import { useModelPickerOpen } from "../modelPickerOpenState";87import { useModelPickerOpen } from "../modelPickerOpenState";
87import { useShortcutModifierState } from "../shortcutModifierState";88import { useShortcutModifierState } from "../shortcutModifierState";
88import { useGitStatus } from "../lib/gitStatusState";89import { useVcsStatus } from "../lib/vcsStatusState";
89import { readLocalApi } from "../localApi";90import { readLocalApi } from "../localApi";
90import { useComposerDraftStore } from "../composerDraftStore";91import { useComposerDraftStore } from "../composerDraftStore";
91import { useNewThreadHandler } from "../hooks/useHandleNewThread";92import { useNewThreadHandler } from "../hooks/useHandleNewThread";
...@@ -342,10 +343,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -342,10 +343,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
342 const threadKey = scopedThreadKey(threadRef);343 const threadKey = scopedThreadKey(threadRef);
343 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);344 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
344 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));345 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
345 const runningTerminalIds = useTerminalStateStore(346 const runningTerminalIds = useThreadRunningTerminalIds({
346 (state) =>347 environmentId: thread.environmentId,
347 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,348 threadId: thread.id,
348 );349 });
349 const primaryEnvironmentId = usePrimaryEnvironmentId();350 const primaryEnvironmentId = usePrimaryEnvironmentId();
350 const isRemoteThread =351 const isRemoteThread =
351 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;352 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
...@@ -370,7 +371,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -370,7 +371,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
370 ),371 ),
371 );372 );
372 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;373 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
373 const gitStatus = useGitStatus({374 const gitStatus = useVcsStatus({
374 environmentId: thread.environmentId,375 environmentId: thread.environmentId,
375 cwd: thread.branch != null ? gitCwd : null,376 cwd: thread.branch != null ? gitCwd : null,
376 });377 });
...@@ -2926,8 +2927,8 @@ export default function Sidebar() {...@@ -2926,8 +2927,8 @@ export default function Sidebar() {
2926 () => ({2927 () => ({
2927 terminalFocus: isTerminalFocused(),2928 terminalFocus: isTerminalFocused(),
2928 terminalOpen: routeThreadRef2929 terminalOpen: routeThreadRef
2929 ? selectThreadTerminalState(2930 ? selectThreadTerminalUiState(
2930 useTerminalStateStore.getState().terminalStateByThreadKey,2931 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
2931 routeThreadRef,2932 routeThreadRef,
2932 ).terminalOpen2933 ).terminalOpen
2933 : false,2934 : false,
...@@ -3134,8 +3135,8 @@ export default function Sidebar() {...@@ -3134,8 +3135,8 @@ export default function Sidebar() {
3134 () => ({3135 () => ({
3135 terminalFocus: false,3136 terminalFocus: false,
3136 terminalOpen: routeThreadRef3137 terminalOpen: routeThreadRef
3137 ? selectThreadTerminalState(3138 ? selectThreadTerminalUiState(
3138 useTerminalStateStore.getState().terminalStateByThreadKey,3139 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
3139 routeThreadRef,3140 routeThreadRef,
3140 ).terminalOpen3141 ).terminalOpen
3141 : false,3142 : false,
apps/web/src/components/ThreadStatusIndicators.tsx+8-9
...@@ -7,9 +7,9 @@ import {...@@ -7,9 +7,9 @@ import {
7 useSavedEnvironmentRegistryStore,7 useSavedEnvironmentRegistryStore,
8 useSavedEnvironmentRuntimeStore,8 useSavedEnvironmentRuntimeStore,
9} from "../environments/runtime";9} from "../environments/runtime";
10import { useGitStatus } from "../lib/gitStatusState";10import { useVcsStatus } from "../lib/vcsStatusState";
11import { type AppState, selectProjectByRef, useStore } from "../store";11import { type AppState, selectProjectByRef, useStore } from "../store";
12import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";12import { useThreadRunningTerminalIds } from "../terminalSessionState";
13import { useUiStateStore } from "../uiStateStore";13import { useUiStateStore } from "../uiStateStore";
14import { resolveChangeRequestPresentation } from "../sourceControlPresentation";14import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
15import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";15import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
...@@ -81,7 +81,7 @@ export function resolveThreadPr(...@@ -81,7 +81,7 @@ export function resolveThreadPr(
81}81}
8282
83export function terminalStatusFromRunningIds(83export function terminalStatusFromRunningIds(
84 runningTerminalIds: string[],84 runningTerminalIds: ReadonlyArray<string>,
85): TerminalStatusIndicator | null {85): TerminalStatusIndicator | null {
86 if (runningTerminalIds.length === 0) {86 if (runningTerminalIds.length === 0) {
87 return null;87 return null;
...@@ -150,7 +150,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -150,7 +150,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
150 ),150 ),
151 );151 );
152 const gitCwd = thread.worktreePath ?? threadProjectCwd;152 const gitCwd = thread.worktreePath ?? threadProjectCwd;
153 const gitStatus = useGitStatus({153 const gitStatus = useVcsStatus({
154 environmentId: thread.environmentId,154 environmentId: thread.environmentId,
155 cwd: thread.branch != null ? gitCwd : null,155 cwd: thread.branch != null ? gitCwd : null,
156 });156 });
...@@ -195,11 +195,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -195,11 +195,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
195 * environment indicator, matching the sidebar's trailing indicators.195 * environment indicator, matching the sidebar's trailing indicators.
196 */196 */
197export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) {197export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) {
198 const threadRef = scopeThreadRef(thread.environmentId, thread.id);198 const runningTerminalIds = useThreadRunningTerminalIds({
199 const runningTerminalIds = useTerminalStateStore(199 environmentId: thread.environmentId,
200 (state) =>200 threadId: thread.id,
201 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,201 });
202 );
203 const primaryEnvironmentId = usePrimaryEnvironmentId();202 const primaryEnvironmentId = usePrimaryEnvironmentId();
204 const isRemoteThread =203 const isRemoteThread =
205 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;204 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+121-23
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import "../index.css";1import "../index.css";
22
3import { scopeThreadRef } from "@t3tools/client-runtime";3import { scopeThreadRef } from "@t3tools/client-runtime";
4import { ThreadId } from "@t3tools/contracts";4import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts";
5import { afterEach, describe, expect, it, vi } from "vitest";5import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";6import { render } from "vitest-browser-react";
77
...@@ -18,7 +18,17 @@ const {...@@ -18,7 +18,17 @@ const {
18 terminalDisposeSpy: vi.fn(),18 terminalDisposeSpy: vi.fn(),
19 fitAddonFitSpy: vi.fn(),19 fitAddonFitSpy: vi.fn(),
20 fitAddonLoadSpy: vi.fn(),20 fitAddonLoadSpy: vi.fn(),
21 environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(),21 environmentApiById: new Map<
22 string,
23 {
24 terminal: {
25 open: ReturnType<typeof vi.fn>;
26 attach: ReturnType<typeof vi.fn>;
27 write: ReturnType<typeof vi.fn>;
28 resize: ReturnType<typeof vi.fn>;
29 };
30 }
31 >(),
22 readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),32 readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),
23 readLocalApiMock: vi.fn<33 readLocalApiMock: vi.fn<
24 () =>34 () =>
...@@ -124,20 +134,33 @@ import { TerminalViewport } from "./ThreadTerminalDrawer";...@@ -124,20 +134,33 @@ import { TerminalViewport } from "./ThreadTerminalDrawer";
124const THREAD_ID = ThreadId.make("thread-terminal-browser");134const THREAD_ID = ThreadId.make("thread-terminal-browser");
125135
126function createEnvironmentApi() {136function createEnvironmentApi() {
137 const snapshot = {
138 threadId: THREAD_ID,
139 terminalId: "term-1",
140 cwd: "/repo/project",
141 worktreePath: null,
142 status: "running" as const,
143 pid: 123,
144 history: "",
145 exitCode: null,
146 exitSignal: null,
147 label: "Terminal 1",
148 updatedAt: "2026-04-07T00:00:00.000Z",
149 };
150
127 return {151 return {
128 terminal: {152 terminal: {
129 open: vi.fn(async () => ({153 open: vi.fn(async () => snapshot),
130 threadId: THREAD_ID,154 attach: vi.fn(
131 terminalId: "default",155 (
132 cwd: "/repo/project",156 _input: unknown,
133 worktreePath: null,157 listener: (event: TerminalAttachStreamEvent) => void,
134 status: "running" as const,158 _options?: unknown,
135 pid: 123,159 ) => {
136 history: "",160 listener({ type: "snapshot", snapshot });
137 exitCode: null,161 return vi.fn();
138 exitSignal: null,162 },
139 updatedAt: "2026-04-07T00:00:00.000Z",163 ),
140 })),
141 write: vi.fn(async () => undefined),164 write: vi.fn(async () => undefined),
142 resize: vi.fn(async () => undefined),165 resize: vi.fn(async () => undefined),
143 },166 },
...@@ -148,6 +171,7 @@ async function mountTerminalViewport(props: {...@@ -148,6 +171,7 @@ async function mountTerminalViewport(props: {
148 threadRef: ReturnType<typeof scopeThreadRef>;171 threadRef: ReturnType<typeof scopeThreadRef>;
149 drawerBackgroundColor?: string;172 drawerBackgroundColor?: string;
150 drawerTextColor?: string;173 drawerTextColor?: string;
174 runtimeEnv?: Record<string, string>;
151}) {175}) {
152 const drawer = document.createElement("div");176 const drawer = document.createElement("div");
153 drawer.className = "thread-terminal-drawer";177 drawer.className = "thread-terminal-drawer";
...@@ -168,9 +192,10 @@ async function mountTerminalViewport(props: {...@@ -168,9 +192,10 @@ async function mountTerminalViewport(props: {
168 <TerminalViewport192 <TerminalViewport
169 threadRef={props.threadRef}193 threadRef={props.threadRef}
170 threadId={THREAD_ID}194 threadId={THREAD_ID}
171 terminalId="default"195 terminalId="term-1"
172 terminalLabel="Terminal"196 terminalLabel="Terminal"
173 cwd="/repo/project"197 cwd="/repo/project"
198 {...(props.runtimeEnv ? { runtimeEnv: props.runtimeEnv } : {})}
174 onSessionExited={() => undefined}199 onSessionExited={() => undefined}
175 onAddTerminalContext={() => undefined}200 onAddTerminalContext={() => undefined}
176 focusRequestId={0}201 focusRequestId={0}
...@@ -183,14 +208,18 @@ async function mountTerminalViewport(props: {...@@ -183,14 +208,18 @@ async function mountTerminalViewport(props: {
183 );208 );
184209
185 return {210 return {
186 rerender: async (nextProps: { threadRef: ReturnType<typeof scopeThreadRef> }) => {211 rerender: async (nextProps: {
212 threadRef: ReturnType<typeof scopeThreadRef>;
213 runtimeEnv?: Record<string, string>;
214 }) => {
187 await screen.rerender(215 await screen.rerender(
188 <TerminalViewport216 <TerminalViewport
189 threadRef={nextProps.threadRef}217 threadRef={nextProps.threadRef}
190 threadId={THREAD_ID}218 threadId={THREAD_ID}
191 terminalId="default"219 terminalId="term-1"
192 terminalLabel="Terminal"220 terminalLabel="Terminal"
193 cwd="/repo/project"221 cwd="/repo/project"
222 {...(nextProps.runtimeEnv ? { runtimeEnv: nextProps.runtimeEnv } : {})}
194 onSessionExited={() => undefined}223 onSessionExited={() => undefined}
195 onAddTerminalContext={() => undefined}224 onAddTerminalContext={() => undefined}
196 focusRequestId={0}225 focusRequestId={0}
...@@ -236,7 +265,48 @@ describe("TerminalViewport", () => {...@@ -236,7 +265,48 @@ describe("TerminalViewport", () => {
236 }265 }
237 });266 });
238267
239 it("reopens the terminal when the scoped thread reference changes", async () => {268 it("renders and attaches the terminal without the desktop local API", async () => {
269 const environment = createEnvironmentApi();
270 environmentApiById.set("environment-a", environment);
271 readLocalApiMock.mockReturnValueOnce(undefined);
272
273 const mounted = await mountTerminalViewport({
274 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
275 });
276
277 try {
278 await vi.waitFor(() => {
279 expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
280 });
281 expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
282 } finally {
283 await mounted.cleanup();
284 }
285 });
286
287 it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => {
288 const environment = createEnvironmentApi();
289 environmentApiById.set("environment-a", environment);
290 fitAddonFitSpy.mockImplementationOnce(() => {
291 throw new TypeError("Cannot read properties of undefined (reading 'dimensions')");
292 });
293
294 const mounted = await mountTerminalViewport({
295 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
296 });
297
298 try {
299 await vi.waitFor(() => {
300 expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
301 });
302 expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
303 expect(fitAddonFitSpy).toHaveBeenCalled();
304 } finally {
305 await mounted.cleanup();
306 }
307 });
308
309 it("reattaches the terminal when the scoped thread reference changes", async () => {
240 const environmentA = createEnvironmentApi();310 const environmentA = createEnvironmentApi();
241 const environmentB = createEnvironmentApi();311 const environmentB = createEnvironmentApi();
242 environmentApiById.set("environment-a", environmentA);312 environmentApiById.set("environment-a", environmentA);
...@@ -248,7 +318,7 @@ describe("TerminalViewport", () => {...@@ -248,7 +318,7 @@ describe("TerminalViewport", () => {
248318
249 try {319 try {
250 await vi.waitFor(() => {320 await vi.waitFor(() => {
251 expect(environmentA.terminal.open).toHaveBeenCalledTimes(1);321 expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1);
252 });322 });
253323
254 await mounted.rerender({324 await mounted.rerender({
...@@ -256,7 +326,7 @@ describe("TerminalViewport", () => {...@@ -256,7 +326,7 @@ describe("TerminalViewport", () => {
256 });326 });
257327
258 await vi.waitFor(() => {328 await vi.waitFor(() => {
259 expect(environmentB.terminal.open).toHaveBeenCalledTimes(1);329 expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1);
260 });330 });
261 expect(terminalDisposeSpy).toHaveBeenCalledTimes(1);331 expect(terminalDisposeSpy).toHaveBeenCalledTimes(1);
262 } finally {332 } finally {
...@@ -264,25 +334,53 @@ describe("TerminalViewport", () => {...@@ -264,25 +334,53 @@ describe("TerminalViewport", () => {
264 }334 }
265 });335 });
266336
267 it("does not reopen the terminal when the scoped thread reference values stay the same", async () => {337 it("does not reattach the terminal when the scoped thread reference values stay the same", async () => {
338 const environment = createEnvironmentApi();
339 environmentApiById.set("environment-a", environment);
340
341 const mounted = await mountTerminalViewport({
342 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
343 });
344
345 try {
346 await vi.waitFor(() => {
347 expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
348 });
349
350 await mounted.rerender({
351 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
352 });
353
354 await vi.waitFor(() => {
355 expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
356 });
357 expect(terminalDisposeSpy).not.toHaveBeenCalled();
358 } finally {
359 await mounted.cleanup();
360 }
361 });
362
363 it("does not reattach when runtime env contents are unchanged but object identity changes", async () => {
268 const environment = createEnvironmentApi();364 const environment = createEnvironmentApi();
269 environmentApiById.set("environment-a", environment);365 environmentApiById.set("environment-a", environment);
270366
271 const mounted = await mountTerminalViewport({367 const mounted = await mountTerminalViewport({
272 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),368 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
369 runtimeEnv: { PATH: "/usr/bin", T3: "1" },
273 });370 });
274371
275 try {372 try {
276 await vi.waitFor(() => {373 await vi.waitFor(() => {
277 expect(environment.terminal.open).toHaveBeenCalledTimes(1);374 expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
278 });375 });
279376
280 await mounted.rerender({377 await mounted.rerender({
281 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),378 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
379 runtimeEnv: { T3: "1", PATH: "/usr/bin" },
282 });380 });
283381
284 await vi.waitFor(() => {382 await vi.waitFor(() => {
285 expect(environment.terminal.open).toHaveBeenCalledTimes(1);383 expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
286 });384 });
287 expect(terminalDisposeSpy).not.toHaveBeenCalled();385 expect(terminalDisposeSpy).not.toHaveBeenCalled();
288 } finally {386 } finally {
apps/web/src/components/ThreadTerminalDrawer.test.ts-62
...@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";...@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
22
3import {3import {
4 resolveTerminalSelectionActionPosition,4 resolveTerminalSelectionActionPosition,
5 selectPendingTerminalEventEntries,
6 selectTerminalEventEntriesAfterSnapshot,
7 shouldHandleTerminalSelectionMouseUp,5 shouldHandleTerminalSelectionMouseUp,
8 terminalSelectionActionDelayForClickCount,6 terminalSelectionActionDelayForClickCount,
9} from "./ThreadTerminalDrawer";7} from "./ThreadTerminalDrawer";
...@@ -74,64 +72,4 @@ describe("resolveTerminalSelectionActionPosition", () => {...@@ -74,64 +72,4 @@ describe("resolveTerminalSelectionActionPosition", () => {
74 expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false);72 expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false);
75 expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false);73 expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false);
76 });74 });
77
78 it("replays only terminal events newer than the open snapshot", () => {
79 expect(
80 selectTerminalEventEntriesAfterSnapshot(
81 [
82 {
83 id: 1,
84 event: {
85 threadId: "thread-1",
86 terminalId: "default",
87 createdAt: "2026-04-02T20:00:00.000Z",
88 type: "output",
89 data: "before",
90 },
91 },
92 {
93 id: 2,
94 event: {
95 threadId: "thread-1",
96 terminalId: "default",
97 createdAt: "2026-04-02T20:00:01.000Z",
98 type: "output",
99 data: "after",
100 },
101 },
102 ],
103 "2026-04-02T20:00:00.500Z",
104 ).map((entry) => entry.id),
105 ).toEqual([2]);
106 });
107
108 it("applies only terminal events that have not already been consumed", () => {
109 expect(
110 selectPendingTerminalEventEntries(
111 [
112 {
113 id: 1,
114 event: {
115 threadId: "thread-1",
116 terminalId: "default",
117 createdAt: "2026-04-02T20:00:00.000Z",
118 type: "output",
119 data: "one",
120 },
121 },
122 {
123 id: 2,
124 event: {
125 threadId: "thread-1",
126 terminalId: "default",
127 createdAt: "2026-04-02T20:00:01.000Z",
128 type: "output",
129 data: "two",
130 },
131 },
132 ],
133 1,
134 ).map((entry) => entry.id),
135 ).toEqual([2]);
136 });
137});75});
apps/web/src/components/ThreadTerminalDrawer.tsx+232-182
...@@ -3,10 +3,11 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci...@@ -3,10 +3,11 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci
3import {3import {
4 type ResolvedKeybindingsConfig,4 type ResolvedKeybindingsConfig,
5 type ScopedThreadRef,5 type ScopedThreadRef,
6 type TerminalEvent,6 type TerminalAttachStreamEvent,
7 type TerminalSessionSnapshot,7 type TerminalSessionSnapshot,
8 type ThreadId,8 type ThreadId,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
10import { Terminal, type ITheme } from "@xterm/xterm";11import { Terminal, type ITheme } from "@xterm/xterm";
11import {12import {
12 type PointerEvent as ReactPointerEvent,13 type PointerEvent as ReactPointerEvent,
...@@ -41,13 +42,12 @@ import {...@@ -41,13 +42,12 @@ import {
41} from "../keybindings";42} from "../keybindings";
42import {43import {
43 DEFAULT_THREAD_TERMINAL_HEIGHT,44 DEFAULT_THREAD_TERMINAL_HEIGHT,
44 DEFAULT_THREAD_TERMINAL_ID,
45 MAX_TERMINALS_PER_GROUP,45 MAX_TERMINALS_PER_GROUP,
46 type ThreadTerminalGroup,46 type ThreadTerminalGroup,
47} from "../types";47} from "../types";
48import { readEnvironmentApi } from "~/environmentApi";48import { readEnvironmentApi } from "~/environmentApi";
49import { readLocalApi } from "~/localApi";49import { readLocalApi } from "~/localApi";
50import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore";50import { attachTerminalSession } from "../terminalSessionState";
5151
52const MIN_DRAWER_HEIGHT = 180;52const MIN_DRAWER_HEIGHT = 180;
53const MAX_DRAWER_HEIGHT_RATIO = 0.75;53const MAX_DRAWER_HEIGHT_RATIO = 0.75;
...@@ -75,18 +75,22 @@ function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnap...@@ -75,18 +75,22 @@ function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnap
75 }75 }
76}76}
7777
78export function selectTerminalEventEntriesAfterSnapshot(78function fitTerminalSafely(fitAddon: FitAddon): boolean {
79 entries: ReadonlyArray<{ id: number; event: TerminalEvent }>,79 try {
80 snapshotUpdatedAt: string,80 fitAddon.fit();
81): ReadonlyArray<{ id: number; event: TerminalEvent }> {81 return true;
82 return entries.filter((entry) => entry.event.createdAt > snapshotUpdatedAt);82 } catch {
83 return false;
84 }
83}85}
8486
85export function selectPendingTerminalEventEntries(87function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {
86 entries: ReadonlyArray<{ id: number; event: TerminalEvent }>,88 if (!runtimeEnv) return "";
87 lastAppliedTerminalEventId: number,89 return JSON.stringify(
88): ReadonlyArray<{ id: number; event: TerminalEvent }> {90 Object.entries(runtimeEnv)
89 return entries.filter((entry) => entry.id > lastAppliedTerminalEventId);91 .filter(([key, value]) => key.length > 0 && typeof value === "string")
92 .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
93 );
90}94}
9195
92function normalizeComputedColor(value: string | null | undefined, fallback: string): string {96function normalizeComputedColor(value: string | null | undefined, fallback: string): string {
...@@ -264,6 +268,12 @@ interface TerminalViewportProps {...@@ -264,6 +268,12 @@ interface TerminalViewportProps {
264 keybindings: ResolvedKeybindingsConfig;268 keybindings: ResolvedKeybindingsConfig;
265}269}
266270
271interface TerminalLaunchLocation {
272 readonly cwd: string;
273 readonly worktreePath?: string | null;
274 readonly runtimeEnv?: Record<string, string>;
275}
276
267export function TerminalViewport({277export function TerminalViewport({
268 threadRef,278 threadRef,
269 threadId,279 threadId,
...@@ -291,8 +301,7 @@ export function TerminalViewport({...@@ -291,8 +301,7 @@ export function TerminalViewport({
291 const selectionActionOpenRef = useRef(false);301 const selectionActionOpenRef = useRef(false);
292 const selectionActionTimerRef = useRef<number | null>(null);302 const selectionActionTimerRef = useRef<number | null>(null);
293 const keybindingsRef = useRef(keybindings);303 const keybindingsRef = useRef(keybindings);
294 const lastAppliedTerminalEventIdRef = useRef(0);304 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
295 const terminalHydratedRef = useRef(false);
296 const handleSessionExited = useEffectEvent(() => {305 const handleSessionExited = useEffectEvent(() => {
297 onSessionExited();306 onSessionExited();
298 });307 });
...@@ -312,7 +321,7 @@ export function TerminalViewport({...@@ -312,7 +321,7 @@ export function TerminalViewport({
312 let disposed = false;321 let disposed = false;
313 const api = readEnvironmentApi(environmentId);322 const api = readEnvironmentApi(environmentId);
314 const localApi = readLocalApi();323 const localApi = readLocalApi();
315 if (!api || !localApi) return;324 if (!api) return;
316325
317 const fitAddon = new FitAddon();326 const fitAddon = new FitAddon();
318 const terminal = new Terminal({327 const terminal = new Terminal({
...@@ -325,7 +334,7 @@ export function TerminalViewport({...@@ -325,7 +334,7 @@ export function TerminalViewport({
325 });334 });
326 terminal.loadAddon(fitAddon);335 terminal.loadAddon(fitAddon);
327 terminal.open(mount);336 terminal.open(mount);
328 fitAddon.fit();337 fitTerminalSafely(fitAddon);
329338
330 terminalRef.current = terminal;339 terminalRef.current = terminal;
331 fitAddonRef.current = fitAddon;340 fitAddonRef.current = fitAddon;
...@@ -379,6 +388,10 @@ export function TerminalViewport({...@@ -379,6 +388,10 @@ export function TerminalViewport({
379 };388 };
380389
381 const showSelectionAction = async () => {390 const showSelectionAction = async () => {
391 if (!localApi) {
392 clearSelectionAction();
393 return;
394 }
382 if (selectionActionOpenRef.current) {395 if (selectionActionOpenRef.current) {
383 return;396 return;
384 }397 }
...@@ -489,6 +502,10 @@ export function TerminalViewport({...@@ -489,6 +502,10 @@ export function TerminalViewport({
489502
490 const latestTerminal = terminalRef.current;503 const latestTerminal = terminalRef.current;
491 if (!latestTerminal) return;504 if (!latestTerminal) return;
505 if (!localApi) {
506 writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser.");
507 return;
508 }
492509
493 if (match.kind === "url") {510 if (match.kind === "url") {
494 void localApi.shell.openExternal(match.text).catch((error: unknown) => {511 void localApi.shell.openExternal(match.text).catch((error: unknown) => {
...@@ -567,7 +584,7 @@ export function TerminalViewport({...@@ -567,7 +584,7 @@ export function TerminalViewport({
567 attributeFilter: ["class", "style"],584 attributeFilter: ["class", "style"],
568 });585 });
569586
570 const applyTerminalEvent = (event: TerminalEvent) => {587 const applyAttachEvent = (event: TerminalAttachStreamEvent) => {
571 const activeTerminal = terminalRef.current;588 const activeTerminal = terminalRef.current;
572 if (!activeTerminal) {589 if (!activeTerminal) {
573 return;590 return;
...@@ -577,13 +594,20 @@ export function TerminalViewport({...@@ -577,13 +594,20 @@ export function TerminalViewport({
577 return;594 return;
578 }595 }
579596
597 if (event.type === "snapshot") {
598 hasHandledExitRef.current = false;
599 clearSelectionAction();
600 writeTerminalSnapshot(activeTerminal, event.snapshot);
601 return;
602 }
603
580 if (event.type === "output") {604 if (event.type === "output") {
581 activeTerminal.write(event.data);605 activeTerminal.write(event.data);
582 clearSelectionAction();606 clearSelectionAction();
583 return;607 return;
584 }608 }
585609
586 if (event.type === "started" || event.type === "restarted") {610 if (event.type === "restarted") {
587 hasHandledExitRef.current = false;611 hasHandledExitRef.current = false;
588 clearSelectionAction();612 clearSelectionAction();
589 writeTerminalSnapshot(activeTerminal, event.snapshot);613 writeTerminalSnapshot(activeTerminal, event.snapshot);
...@@ -602,16 +626,21 @@ export function TerminalViewport({...@@ -602,16 +626,21 @@ export function TerminalViewport({
602 return;626 return;
603 }627 }
604628
605 const details = [629 if (event.type === "closed") {
606 typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,630 writeSystemMessage(activeTerminal, "Terminal closed");
607 typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,631 } else {
608 ]632 const details = [
609 .filter((value): value is string => value !== null)633 typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,
610 .join(", ");634 typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,
611 writeSystemMessage(635 ]
612 activeTerminal,636 .filter((value): value is string => value !== null)
613 details.length > 0 ? `Process exited (${details})` : "Process exited",637 .join(", ");
614 );638 writeSystemMessage(
639 activeTerminal,
640 details.length > 0 ? `Process exited (${details})` : "Process exited",
641 );
642 }
643
615 if (hasHandledExitRef.current) {644 if (hasHandledExitRef.current) {
616 return;645 return;
617 }646 }
...@@ -623,54 +652,16 @@ export function TerminalViewport({...@@ -623,54 +652,16 @@ export function TerminalViewport({
623 handleSessionExited();652 handleSessionExited();
624 }, 0);653 }, 0);
625 };654 };
626 const applyPendingTerminalEvents = (655 let unsubscribeAttach: (() => void) | null = null;
627 terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>,656 const attachTerminal = () => {
628 ) => {657 const activeTerminal = terminalRef.current;
629 const pendingEntries = selectPendingTerminalEventEntries(658 const activeFitAddon = fitAddonRef.current;
630 terminalEventEntries,659 if (!activeTerminal || !activeFitAddon) return;
631 lastAppliedTerminalEventIdRef.current,660 fitTerminalSafely(activeFitAddon);
632 );661 unsubscribeAttach = attachTerminalSession({
633 if (pendingEntries.length === 0) {662 environmentId,
634 return;663 client: api,
635 }664 terminal: {
636 for (const entry of pendingEntries) {
637 applyTerminalEvent(entry.event);
638 }
639 lastAppliedTerminalEventIdRef.current =
640 pendingEntries.at(-1)?.id ?? lastAppliedTerminalEventIdRef.current;
641 };
642
643 const unsubscribeTerminalEvents = useTerminalStateStore.subscribe((state, previousState) => {
644 if (!terminalHydratedRef.current) {
645 return;
646 }
647
648 const previousLastEntryId =
649 selectTerminalEventEntries(
650 previousState.terminalEventEntriesByKey,
651 threadRef,
652 terminalId,
653 ).at(-1)?.id ?? 0;
654 const nextEntries = selectTerminalEventEntries(
655 state.terminalEventEntriesByKey,
656 threadRef,
657 terminalId,
658 );
659 const nextLastEntryId = nextEntries.at(-1)?.id ?? 0;
660 if (nextLastEntryId === previousLastEntryId) {
661 return;
662 }
663
664 applyPendingTerminalEvents(nextEntries);
665 });
666
667 const openTerminal = async () => {
668 try {
669 const activeTerminal = terminalRef.current;
670 const activeFitAddon = fitAddonRef.current;
671 if (!activeTerminal || !activeFitAddon) return;
672 activeFitAddon.fit();
673 const snapshot = await api.terminal.open({
674 threadId,665 threadId,
675 terminalId,666 terminalId,
676 cwd,667 cwd,
...@@ -678,35 +669,20 @@ export function TerminalViewport({...@@ -678,35 +669,20 @@ export function TerminalViewport({
678 cols: activeTerminal.cols,669 cols: activeTerminal.cols,
679 rows: activeTerminal.rows,670 rows: activeTerminal.rows,
680 ...(runtimeEnv ? { env: runtimeEnv } : {}),671 ...(runtimeEnv ? { env: runtimeEnv } : {}),
681 });672 },
682 if (disposed) return;673 onEvent: (event) => {
683 writeTerminalSnapshot(activeTerminal, snapshot);674 if (disposed) return;
684 const bufferedEntries = selectTerminalEventEntries(675 applyAttachEvent(event);
685 useTerminalStateStore.getState().terminalEventEntriesByKey,676 },
686 threadRef,677 onSnapshot: () => {
687 terminalId,678 if (disposed) return;
688 );679 if (autoFocus) {
689 const replayEntries = selectTerminalEventEntriesAfterSnapshot(680 window.requestAnimationFrame(() => {
690 bufferedEntries,681 activeTerminal.focus();
691 snapshot.updatedAt,682 });
692 );683 }
693 for (const entry of replayEntries) {684 },
694 applyTerminalEvent(entry.event);685 });
695 }
696 lastAppliedTerminalEventIdRef.current = bufferedEntries.at(-1)?.id ?? 0;
697 terminalHydratedRef.current = true;
698 if (autoFocus) {
699 window.requestAnimationFrame(() => {
700 activeTerminal.focus();
701 });
702 }
703 } catch (err) {
704 if (disposed) return;
705 writeSystemMessage(
706 terminal,
707 err instanceof Error ? err.message : "Failed to open terminal",
708 );
709 }
710 };686 };
711687
712 const fitTimer = window.setTimeout(() => {688 const fitTimer = window.setTimeout(() => {
...@@ -715,7 +691,7 @@ export function TerminalViewport({...@@ -715,7 +691,7 @@ export function TerminalViewport({
715 if (!activeTerminal || !activeFitAddon) return;691 if (!activeTerminal || !activeFitAddon) return;
716 const wasAtBottom =692 const wasAtBottom =
717 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;693 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
718 activeFitAddon.fit();694 fitTerminalSafely(activeFitAddon);
719 if (wasAtBottom) {695 if (wasAtBottom) {
720 activeTerminal.scrollToBottom();696 activeTerminal.scrollToBottom();
721 }697 }
...@@ -728,13 +704,12 @@ export function TerminalViewport({...@@ -728,13 +704,12 @@ export function TerminalViewport({
728 })704 })
729 .catch(() => undefined);705 .catch(() => undefined);
730 }, 30);706 }, 30);
731 void openTerminal();707 attachTerminal();
732708
733 return () => {709 return () => {
734 disposed = true;710 disposed = true;
735 terminalHydratedRef.current = false;711 unsubscribeAttach?.();
736 lastAppliedTerminalEventIdRef.current = 0;712 unsubscribeAttach = null;
737 unsubscribeTerminalEvents();
738 window.clearTimeout(fitTimer);713 window.clearTimeout(fitTimer);
739 inputDisposable.dispose();714 inputDisposable.dispose();
740 selectionDisposable.dispose();715 selectionDisposable.dispose();
...@@ -752,7 +727,7 @@ export function TerminalViewport({...@@ -752,7 +727,7 @@ export function TerminalViewport({
752 // autoFocus is intentionally omitted;727 // autoFocus is intentionally omitted;
753 // it is only read at mount time and must not trigger terminal teardown/recreation.728 // it is only read at mount time and must not trigger terminal teardown/recreation.
754 // eslint-disable-next-line react-hooks/exhaustive-deps729 // eslint-disable-next-line react-hooks/exhaustive-deps
755 }, [cwd, environmentId, runtimeEnv, terminalId, threadId]);730 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
756731
757 useEffect(() => {732 useEffect(() => {
758 if (!autoFocus) return;733 if (!autoFocus) return;
...@@ -773,7 +748,7 @@ export function TerminalViewport({...@@ -773,7 +748,7 @@ export function TerminalViewport({
773 if (!api || !terminal || !fitAddon) return;748 if (!api || !terminal || !fitAddon) return;
774 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;749 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
775 const frame = window.requestAnimationFrame(() => {750 const frame = window.requestAnimationFrame(() => {
776 fitAddon.fit();751 fitTerminalSafely(fitAddon);
777 if (wasAtBottom) {752 if (wasAtBottom) {
778 terminal.scrollToBottom();753 terminal.scrollToBottom();
779 }754 }
...@@ -821,6 +796,10 @@ interface ThreadTerminalDrawerProps {...@@ -821,6 +796,10 @@ interface ThreadTerminalDrawerProps {
821 onHeightChange: (height: number) => void;796 onHeightChange: (height: number) => void;
822 onAddTerminalContext: (selection: TerminalContextSelection) => void;797 onAddTerminalContext: (selection: TerminalContextSelection) => void;
823 keybindings: ResolvedKeybindingsConfig;798 keybindings: ResolvedKeybindingsConfig;
799 /** Prefer server-provided tab titles when present (e.g. active subprocess name). */
800 terminalLabelsById?: ReadonlyMap<string, string>;
801 /** Prefer per-session launch locations when the server already knows a terminal. */
802 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;
824}803}
825804
826interface TerminalActionButtonProps {805interface TerminalActionButtonProps {
...@@ -875,6 +854,8 @@ export default function ThreadTerminalDrawer({...@@ -875,6 +854,8 @@ export default function ThreadTerminalDrawer({
875 onHeightChange,854 onHeightChange,
876 onAddTerminalContext,855 onAddTerminalContext,
877 keybindings,856 keybindings,
857 terminalLabelsById,
858 terminalLaunchLocationsById,
878}: ThreadTerminalDrawerProps) {859}: ThreadTerminalDrawerProps) {
879 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));860 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
880 const [resizeEpoch, setResizeEpoch] = useState(0);861 const [resizeEpoch, setResizeEpoch] = useState(0);
...@@ -889,15 +870,28 @@ export default function ThreadTerminalDrawer({...@@ -889,15 +870,28 @@ export default function ThreadTerminalDrawer({
889 const didResizeDuringDragRef = useRef(false);870 const didResizeDuringDragRef = useRef(false);
890871
891 const normalizedTerminalIds = useMemo(() => {872 const normalizedTerminalIds = useMemo(() => {
892 const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];873 const normalizedIds: string[] = [];
893 return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID];874 const seen = new Set<string>();
875 for (const id of terminalIds) {
876 const trimmedId = id.trim();
877 if (trimmedId.length === 0 || seen.has(trimmedId)) continue;
878 seen.add(trimmedId);
879 normalizedIds.push(trimmedId);
880 }
881 return normalizedIds;
894 }, [terminalIds]);882 }, [terminalIds]);
895883
896 const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId)884 const resolvedActiveTerminalId =
897 ? activeTerminalId885 normalizedTerminalIds.length === 0
898 : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);886 ? ""
887 : normalizedTerminalIds.includes(activeTerminalId)
888 ? activeTerminalId
889 : (normalizedTerminalIds[0] ?? "");
899890
900 const resolvedTerminalGroups = useMemo(() => {891 const resolvedTerminalGroups = useMemo(() => {
892 if (normalizedTerminalIds.length === 0) {
893 return [];
894 }
901 const validTerminalIdSet = new Set(normalizedTerminalIds);895 const validTerminalIdSet = new Set(normalizedTerminalIds);
902 const assignedTerminalIds = new Set<string>();896 const assignedTerminalIds = new Set<string>();
903 const usedGroupIds = new Set<string>();897 const usedGroupIds = new Set<string>();
...@@ -918,13 +912,17 @@ export default function ThreadTerminalDrawer({...@@ -918,13 +912,17 @@ export default function ThreadTerminalDrawer({
918 };912 };
919913
920 for (const terminalGroup of terminalGroups) {914 for (const terminalGroup of terminalGroups) {
921 const nextTerminalIds = [915 const nextTerminalIds: string[] = [];
922 ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)),916 const seenGroupTerminalIds = new Set<string>();
923 ].filter((terminalId) => {917 for (const id of terminalGroup.terminalIds) {
924 if (!validTerminalIdSet.has(terminalId)) return false;918 const terminalId = id.trim();
925 if (assignedTerminalIds.has(terminalId)) return false;919 if (terminalId.length === 0) continue;
926 return true;920 if (seenGroupTerminalIds.has(terminalId)) continue;
927 });921 seenGroupTerminalIds.add(terminalId);
922 if (!validTerminalIdSet.has(terminalId)) continue;
923 if (assignedTerminalIds.has(terminalId)) continue;
924 nextTerminalIds.push(terminalId);
925 }
928 if (nextTerminalIds.length === 0) continue;926 if (nextTerminalIds.length === 0) continue;
929927
930 for (const terminalId of nextTerminalIds) {928 for (const terminalId of nextTerminalIds) {
...@@ -934,7 +932,7 @@ export default function ThreadTerminalDrawer({...@@ -934,7 +932,7 @@ export default function ThreadTerminalDrawer({
934 const baseGroupId =932 const baseGroupId =
935 terminalGroup.id.trim().length > 0933 terminalGroup.id.trim().length > 0
936 ? terminalGroup.id.trim()934 ? terminalGroup.id.trim()
937 : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`;935 : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`;
938 nextGroups.push({936 nextGroups.push({
939 id: assignUniqueGroupId(baseGroupId),937 id: assignUniqueGroupId(baseGroupId),
940 terminalIds: nextTerminalIds,938 terminalIds: nextTerminalIds,
...@@ -949,17 +947,17 @@ export default function ThreadTerminalDrawer({...@@ -949,17 +947,17 @@ export default function ThreadTerminalDrawer({
949 });947 });
950 }948 }
951949
952 if (nextGroups.length > 0) {950 const terminalOrderIndex = new Map(
953 return nextGroups;951 normalizedTerminalIds.map((id, index) => [id, index] as const),
954 }952 );
953 nextGroups.sort((left, right) => {
954 const rank = (ids: readonly string[]) =>
955 Math.min(...ids.map((id) => terminalOrderIndex.get(id) ?? Number.POSITIVE_INFINITY));
956 return rank(left.terminalIds) - rank(right.terminalIds);
957 });
955958
956 return [959 return nextGroups;
957 {960 }, [normalizedTerminalIds, terminalGroups]);
958 id: `group-${resolvedActiveTerminalId}`,
959 terminalIds: [resolvedActiveTerminalId],
960 },
961 ];
962 }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]);
963961
964 const resolvedActiveGroupIndex = useMemo(() => {962 const resolvedActiveGroupIndex = useMemo(() => {
965 const indexById = resolvedTerminalGroups.findIndex(963 const indexById = resolvedTerminalGroups.findIndex(
...@@ -972,21 +970,33 @@ export default function ThreadTerminalDrawer({...@@ -972,21 +970,33 @@ export default function ThreadTerminalDrawer({
972 return indexByTerminal >= 0 ? indexByTerminal : 0;970 return indexByTerminal >= 0 ? indexByTerminal : 0;
973 }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]);971 }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]);
974972
975 const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [973 const visibleTerminalIds =
976 resolvedActiveTerminalId,974 resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ??
977 ];975 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
978 const hasTerminalSidebar = normalizedTerminalIds.length > 1;976 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
979 const isSplitView = visibleTerminalIds.length > 1;977 const isSplitView = visibleTerminalIds.length > 1;
980 const showGroupHeaders =978 const showGroupHeaders =
981 resolvedTerminalGroups.length > 1 ||979 resolvedTerminalGroups.length > 1 ||
982 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);980 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
983 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;981 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
984 const terminalLabelById = useMemo(982 const terminalLabelById = useMemo(() => {
985 () =>983 const next = new Map<string, string>();
986 new Map(984 for (const terminalId of normalizedTerminalIds) {
987 normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]),985 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));
988 ),986 }
989 [normalizedTerminalIds],987 return next;
988 }, [normalizedTerminalIds, terminalLabelsById]);
989 const resolveTerminalLaunchLocation = useCallback(
990 (terminalId: string): TerminalLaunchLocation => {
991 return (
992 terminalLaunchLocationsById?.get(terminalId) ?? {
993 cwd,
994 ...(worktreePath !== undefined ? { worktreePath } : {}),
995 ...(runtimeEnv ? { runtimeEnv } : {}),
996 }
997 );
998 },
999 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
990 );1000 );
991 const splitTerminalActionLabel = hasReachedSplitLimit1001 const splitTerminalActionLabel = hasReachedSplitLimit
992 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`1002 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`
...@@ -1109,6 +1119,35 @@ export default function ThreadTerminalDrawer({...@@ -1109,6 +1119,35 @@ export default function ThreadTerminalDrawer({
1109 };1119 };
1110 }, [syncHeight]);1120 }, [syncHeight]);
11111121
1122 if (normalizedTerminalIds.length === 0) {
1123 return (
1124 <aside
1125 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
1126 style={{ height: `${drawerHeight}px` }}
1127 >
1128 <div
1129 className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
1130 onPointerDown={handleResizePointerDown}
1131 onPointerMove={handleResizePointerMove}
1132 onPointerUp={handleResizePointerEnd}
1133 onPointerCancel={handleResizePointerEnd}
1134 />
1135 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground">
1136 <p>No terminal sessions for this thread yet.</p>
1137 <button
1138 type="button"
1139 className="rounded-md border border-border/80 bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
1140 onClick={onNewTerminalAction}
1141 >
1142 {newTerminalActionLabel}
1143 </button>
1144 </div>
1145 </aside>
1146 );
1147 }
1148
1149 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);
1150
1112 return (1151 return (
1113 <aside1152 <aside
1114 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"1153 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
...@@ -1166,38 +1205,47 @@ export default function ThreadTerminalDrawer({...@@ -1166,38 +1205,47 @@ export default function ThreadTerminalDrawer({
1166 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,1205 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1167 }}1206 }}
1168 >1207 >
1169 {visibleTerminalIds.map((terminalId) => (1208 {visibleTerminalIds.map((terminalId) => {
1170 <div1209 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1171 key={terminalId}1210 return (
1172 className={`min-h-0 min-w-0 border-l first:border-l-0 ${1211 <div
1173 terminalId === resolvedActiveTerminalId ? "border-border" : "border-border/70"1212 key={terminalId}
1174 }`}1213 className={`min-h-0 min-w-0 border-l first:border-l-0 ${
1175 onMouseDown={() => {1214 terminalId === resolvedActiveTerminalId
1176 if (terminalId !== resolvedActiveTerminalId) {1215 ? "border-border"
1177 onActiveTerminalChange(terminalId);1216 : "border-border/70"
1178 }1217 }`}
1179 }}1218 onMouseDown={() => {
1180 >1219 if (terminalId !== resolvedActiveTerminalId) {
1181 <div className="h-full p-1">1220 onActiveTerminalChange(terminalId);
1182 <TerminalViewport1221 }
1183 threadRef={threadRef}1222 }}
1184 threadId={threadId}1223 >
1185 terminalId={terminalId}1224 <div className="h-full p-1">
1186 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}1225 <TerminalViewport
1187 cwd={cwd}1226 threadRef={threadRef}
1188 {...(worktreePath !== undefined ? { worktreePath } : {})}1227 threadId={threadId}
1189 {...(runtimeEnv ? { runtimeEnv } : {})}1228 terminalId={terminalId}
1190 onSessionExited={() => onCloseTerminal(terminalId)}1229 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1191 onAddTerminalContext={onAddTerminalContext}1230 cwd={terminalLaunchLocation.cwd}
1192 focusRequestId={focusRequestId}1231 {...(terminalLaunchLocation.worktreePath !== undefined
1193 autoFocus={terminalId === resolvedActiveTerminalId}1232 ? { worktreePath: terminalLaunchLocation.worktreePath }
1194 resizeEpoch={resizeEpoch}1233 : {})}
1195 drawerHeight={drawerHeight}1234 {...(terminalLaunchLocation.runtimeEnv
1196 keybindings={keybindings}1235 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }
1197 />1236 : {})}
1237 onSessionExited={() => onCloseTerminal(terminalId)}
1238 onAddTerminalContext={onAddTerminalContext}
1239 focusRequestId={focusRequestId}
1240 autoFocus={terminalId === resolvedActiveTerminalId}
1241 resizeEpoch={resizeEpoch}
1242 drawerHeight={drawerHeight}
1243 keybindings={keybindings}
1244 />
1245 </div>
1198 </div>1246 </div>
1199 </div>1247 );
1200 ))}1248 })}
1201 </div>1249 </div>
1202 ) : (1250 ) : (
1203 <div className="h-full p-1">1251 <div className="h-full p-1">
...@@ -1207,9 +1255,13 @@ export default function ThreadTerminalDrawer({...@@ -1207,9 +1255,13 @@ export default function ThreadTerminalDrawer({
1207 threadId={threadId}1255 threadId={threadId}
1208 terminalId={resolvedActiveTerminalId}1256 terminalId={resolvedActiveTerminalId}
1209 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}1257 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1210 cwd={cwd}1258 cwd={activeTerminalLaunchLocation.cwd}
1211 {...(worktreePath !== undefined ? { worktreePath } : {})}1259 {...(activeTerminalLaunchLocation.worktreePath !== undefined
1212 {...(runtimeEnv ? { runtimeEnv } : {})}1260 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }
1261 : {})}
1262 {...(activeTerminalLaunchLocation.runtimeEnv
1263 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }
1264 : {})}
1213 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}1265 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1214 onAddTerminalContext={onAddTerminalContext}1266 onAddTerminalContext={onAddTerminalContext}
1215 focusRequestId={focusRequestId}1267 focusRequestId={focusRequestId}
...@@ -1274,9 +1326,7 @@ export default function ThreadTerminalDrawer({...@@ -1274,9 +1326,7 @@ export default function ThreadTerminalDrawer({
1274 }`}1326 }`}
1275 onClick={() => onActiveTerminalChange(groupActiveTerminalId)}1327 onClick={() => onActiveTerminalChange(groupActiveTerminalId)}
1276 >1328 >
1277 {terminalGroup.terminalIds.length > 11329 Group {groupIndex + 1}
1278 ? `Split ${groupIndex + 1}`
1279 : `Terminal ${groupIndex + 1}`}
1280 </button>1330 </button>
1281 )}1331 )}
12821332
apps/web/src/components/chat/ChatComposer.tsx+9-28
...@@ -2,7 +2,6 @@ import type {...@@ -2,7 +2,6 @@ import type {
2 ApprovalRequestId,2 ApprovalRequestId,
3 EnvironmentId,3 EnvironmentId,
4 ModelSelection,4 ModelSelection,
5 ProjectEntry,
6 ProviderApprovalDecision,5 ProviderApprovalDecision,
7 ProviderInteractionMode,6 ProviderInteractionMode,
8 ResolvedKeybindingsConfig,7 ResolvedKeybindingsConfig,
...@@ -29,9 +28,6 @@ import {...@@ -29,9 +28,6 @@ import {
29 useRef,28 useRef,
30 useState,29 useState,
31} from "react";30} from "react";
32import { useQuery } from "@tanstack/react-query";
33import { useDebouncedValue } from "@tanstack/react-pacer";
34import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
35import {31import {
36 clampCollapsedComposerCursor,32 clampCollapsedComposerCursor,
37 type ComposerTrigger,33 type ComposerTrigger,
...@@ -55,6 +51,7 @@ import {...@@ -55,6 +51,7 @@ import {
55 insertInlineTerminalContextPlaceholder,51 insertInlineTerminalContextPlaceholder,
56 removeInlineTerminalContextPlaceholder,52 removeInlineTerminalContextPlaceholder,
57} from "../../lib/terminalContext";53} from "../../lib/terminalContext";
54import { useComposerPathSearch } from "../../lib/composerPathSearchState";
58import {55import {
59 shouldUseCompactComposerPrimaryActions,56 shouldUseCompactComposerPrimaryActions,
60 shouldUseCompactComposerFooter,57 shouldUseCompactComposerFooter,
...@@ -136,8 +133,6 @@ const runtimeModeConfig: Record<...@@ -136,8 +133,6 @@ const runtimeModeConfig: Record<
136};133};
137134
138const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];135const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
139const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
140const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
141const COMPOSER_FLOATING_LAYER_SELECTOR = [136const COMPOSER_FLOATING_LAYER_SELECTOR = [
142 '[data-slot="popover-popup"]',137 '[data-slot="popover-popup"]',
143 '[data-slot="menu-popup"]',138 '[data-slot="menu-popup"]',
...@@ -840,27 +835,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -840,27 +835,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
840 const composerTriggerKind = composerTrigger?.kind ?? null;835 const composerTriggerKind = composerTrigger?.kind ?? null;
841 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";836 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
842 const isPathTrigger = composerTriggerKind === "path";837 const isPathTrigger = composerTriggerKind === "path";
843 const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(838 const workspaceEntries = useComposerPathSearch({
844 pathTriggerQuery,839 environmentId,
845 { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },840 cwd: isPathTrigger ? gitCwd : null,
846 (debouncerState) => ({ isPending: debouncerState.isPending }),841 query: isPathTrigger ? pathTriggerQuery : null,
847 );842 });
848 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
849 const workspaceEntriesQuery = useQuery(
850 projectSearchEntriesQueryOptions({
851 environmentId,
852 cwd: gitCwd,
853 query: effectivePathQuery,
854 enabled: isPathTrigger,
855 limit: 80,
856 }),
857 );
858 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
859843
860 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {844 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
861 if (!composerTrigger) return [];845 if (!composerTrigger) return [];
862 if (composerTrigger.kind === "path") {846 if (composerTrigger.kind === "path") {
863 return workspaceEntries.map((entry) => ({847 return workspaceEntries.entries.map((entry) => ({
864 id: `path:${entry.kind}:${entry.path}`,848 id: `path:${entry.kind}:${entry.path}`,
865 type: "path",849 type: "path",
866 path: entry.path,850 path: entry.path,
...@@ -926,7 +910,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -926,7 +910,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
926 );910 );
927 }911 }
928 return [];912 return [];
929 }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]);913 }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]);
930914
931 const composerMenuOpen = Boolean(composerTrigger);915 const composerMenuOpen = Boolean(composerTrigger);
932 const composerMenuSearchKey = composerTrigger916 const composerMenuSearchKey = composerTrigger
...@@ -991,10 +975,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -991,10 +975,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
991 ]);975 ]);
992976
993 const isComposerMenuLoading =977 const isComposerMenuLoading =
994 composerTriggerKind === "path" &&978 composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending;
995 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
996 workspaceEntriesQuery.isLoading ||
997 workspaceEntriesQuery.isFetching);
998 const composerMenuEmptyState = useMemo(() => {979 const composerMenuEmptyState = useMemo(() => {
999 if (composerTriggerKind === "skill") {980 if (composerTriggerKind === "skill") {
1000 return "No skills found. Try / to browse provider commands.";981 return "No skills found. Try / to browse provider commands.";
apps/web/src/components/chat/MessagesTimeline.test.tsx+58
...@@ -27,6 +27,25 @@ vi.mock("@legendapp/list/react", async () => {...@@ -27,6 +27,25 @@ vi.mock("@legendapp/list/react", async () => {
27 return { LegendList };27 return { LegendList };
28});28});
2929
30function MockFileDiff(props: {
31 fileDiff: { name?: string | null; prevName?: string | null };
32 renderCustomHeader?: (fileDiff: {
33 name?: string | null;
34 prevName?: string | null;
35 }) => React.ReactNode;
36}) {
37 return (
38 <div data-testid="file-diff">
39 {props.renderCustomHeader?.(props.fileDiff)}
40 {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"}
41 </div>
42 );
43}
44
45vi.mock("@pierre/diffs/react", () => {
46 return { FileDiff: MockFileDiff };
47});
48
30function matchMedia() {49function matchMedia() {
31 return {50 return {
32 matches: false,51 matches: false,
...@@ -238,4 +257,43 @@ describe("MessagesTimeline", () => {...@@ -238,4 +257,43 @@ describe("MessagesTimeline", () => {
238 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");257 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");
239 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");258 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
240 });259 });
260
261 it("renders review comment contexts as structured cards instead of raw tags", async () => {
262 const { MessagesTimeline } = await import("./MessagesTimeline");
263 const markup = renderToStaticMarkup(
264 <MessagesTimeline
265 {...buildProps()}
266 timelineEntries={[
267 {
268 id: "entry-1",
269 kind: "message",
270 createdAt: "2026-03-17T19:12:28.000Z",
271 message: {
272 id: MessageId.make("message-2"),
273 role: "user",
274 text: [
275 '<review_comment sectionId="turn:2" sectionTitle="Turn 2" filePath="apps/web/src/lib/contextWindow.test.ts" startIndex="3" endIndex="14" rangeLabel="+47 to +58">',
276 "Wadduo",
277 "```diff",
278 "@@ -0,0 +47,2 @@",
279 '+ it("keeps valid zero-usage snapshots", () => {',
280 "+ expect(snapshot).not.toBeNull();",
281 "```",
282 "</review_comment>",
283 ].join("\n"),
284 createdAt: "2026-03-17T19:12:28.000Z",
285 streaming: false,
286 },
287 },
288 ]}
289 />,
290 );
291
292 expect(markup).toContain("contextWindow.test.ts");
293 expect(markup).toContain("Wadduo");
294 expect(markup).toContain('data-testid="file-diff"');
295 expect(markup).not.toContain(">Review comment<");
296 expect(markup).not.toContain("&lt;review_comment");
297 expect(markup).not.toContain("&lt;/review_comment&gt;");
298 });
241});299});
apps/web/src/components/chat/MessagesTimeline.tsx+73
...@@ -16,9 +16,15 @@ import {...@@ -16,9 +16,15 @@ import {
16 type ReactNode,16 type ReactNode,
17} from "react";17} from "react";
18import { LegendList, type LegendListRef } from "@legendapp/list/react";18import { LegendList, type LegendListRef } from "@legendapp/list/react";
19import { FileDiff } from "@pierre/diffs/react";
19import { deriveTimelineEntries, formatElapsed } from "../../session-logic";20import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
20import { type TurnDiffSummary } from "../../types";21import { type TurnDiffSummary } from "../../types";
21import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";22import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
23import {
24 getRenderablePatch,
25 resolveDiffThemeName,
26 resolveFileDiffPath,
27} from "../../lib/diffRendering";
22import ChatMarkdown from "../ChatMarkdown";28import ChatMarkdown from "../ChatMarkdown";
23import {29import {
24 BotIcon,30 BotIcon,
...@@ -67,6 +73,11 @@ import {...@@ -67,6 +73,11 @@ import {
67} from "./userMessageTerminalContexts";73} from "./userMessageTerminalContexts";
68import { SkillInlineText } from "./SkillInlineText";74import { SkillInlineText } from "./SkillInlineText";
69import { formatWorkspaceRelativePath } from "../../filePathDisplay";75import { formatWorkspaceRelativePath } from "../../filePathDisplay";
76import {
77 buildReviewCommentRenderablePatch,
78 parseReviewCommentMessageSegments,
79 type ReviewCommentContext,
80} from "../../reviewCommentContext";
7081
71// ---------------------------------------------------------------------------82// ---------------------------------------------------------------------------
72// Context — shared state consumed by every row component via Context.83// Context — shared state consumed by every row component via Context.
...@@ -837,6 +848,25 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -837,6 +848,25 @@ const UserMessageBody = memo(function UserMessageBody(props: {
837 terminalContexts: ParsedTerminalContextEntry[];848 terminalContexts: ParsedTerminalContextEntry[];
838 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;849 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
839}) {850}) {
851 const reviewCommentSegments = parseReviewCommentMessageSegments(props.text);
852 if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) {
853 return (
854 <div className="space-y-3 text-sm leading-relaxed text-foreground">
855 {reviewCommentSegments.map((segment) =>
856 segment.kind === "text" ? (
857 segment.text.trim().length > 0 ? (
858 <div key={segment.id} className="whitespace-pre-wrap wrap-break-word">
859 <SkillInlineText text={segment.text.trim()} skills={props.skills} />
860 </div>
861 ) : null
862 ) : (
863 <UserMessageReviewCommentCard key={segment.comment.id} comment={segment.comment} />
864 ),
865 )}
866 </div>
867 );
868 }
869
840 if (props.terminalContexts.length > 0) {870 if (props.terminalContexts.length > 0) {
841 const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels(871 const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels(
842 props.text,872 props.text,
...@@ -930,6 +960,49 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -930,6 +960,49 @@ const UserMessageBody = memo(function UserMessageBody(props: {
930 );960 );
931});961});
932962
963function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) {
964 const ctx = use(TimelineRowCtx);
965 const renderablePatch = getRenderablePatch(
966 buildReviewCommentRenderablePatch(comment),
967 `review-comment:${comment.id}`,
968 );
969
970 return (
971 <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3">
972 <div className="space-y-1">
973 <div className="text-xs font-medium text-foreground">
974 {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
975 </div>
976 <div className="text-[11px] text-muted-foreground">
977 {comment.sectionTitle} · {comment.rangeLabel}
978 </div>
979 </div>
980 {comment.text.length > 0 && (
981 <div className="whitespace-pre-wrap wrap-break-word text-sm">
982 <SkillInlineText text={comment.text} skills={ctx.skills} />
983 </div>
984 )}
985 {renderablePatch?.kind === "files" &&
986 renderablePatch.files.map((fileDiff) => (
987 <FileDiff
988 key={resolveFileDiffPath(fileDiff)}
989 fileDiff={fileDiff}
990 options={{
991 collapsed: false,
992 diffStyle: "unified",
993 theme: resolveDiffThemeName(ctx.resolvedTheme),
994 }}
995 />
996 ))}
997 {renderablePatch?.kind === "raw" && (
998 <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs">
999 {renderablePatch.text}
1000 </pre>
1001 )}
1002 </div>
1003 );
1004}
1005
933// ---------------------------------------------------------------------------1006// ---------------------------------------------------------------------------
934// Structural sharing — reuse old row references when data hasn't changed1007// Structural sharing — reuse old row references when data hasn't changed
935// so LegendList (and React) can skip re-rendering unchanged items.1008// so LegendList (and React) can skip re-rendering unchanged items.
apps/web/src/components/settings/ConnectionsSettings.tsx+1-1
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 type DesktopServerExposureState,17 type DesktopServerExposureState,
18 type EnvironmentId,18 type EnvironmentId,
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import { WsRpcClient } from "@t3tools/client-runtime";
20import * as DateTime from "effect/DateTime";21import * as DateTime from "effect/DateTime";
2122
22import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";23import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
...@@ -82,7 +83,6 @@ import {...@@ -82,7 +83,6 @@ import {
82 type ServerClientSessionRecord,83 type ServerClientSessionRecord,
83 type ServerPairingLinkRecord,84 type ServerPairingLinkRecord,
84} from "~/environments/primary";85} from "~/environments/primary";
85import type { WsRpcClient } from "~/rpc/wsRpcClient";
86import {86import {
87 type SavedEnvironmentRecord,87 type SavedEnvironmentRecord,
88 type SavedEnvironmentRuntimeState,88 type SavedEnvironmentRuntimeState,
apps/web/src/environmentApi.ts+7-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts";1import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts";
22
3import type { WsRpcClient } from "./rpc/wsRpcClient";3import type { WsRpcClient } from "@t3tools/client-runtime";
4import { readEnvironmentConnection } from "./environments/runtime";4import { readEnvironmentConnection } from "./environments/runtime";
55
6const environmentApiOverridesForTests = new Map<EnvironmentId, EnvironmentApi>();6const environmentApiOverridesForTests = new Map<EnvironmentId, EnvironmentApi>();
...@@ -9,12 +9,14 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {...@@ -9,12 +9,14 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
9 return {9 return {
10 terminal: {10 terminal: {
11 open: (input) => rpcClient.terminal.open(input as never),11 open: (input) => rpcClient.terminal.open(input as never),
12 attach: (input, callback, options) =>
13 rpcClient.terminal.attach(input as never, callback, options),
12 write: (input) => rpcClient.terminal.write(input as never),14 write: (input) => rpcClient.terminal.write(input as never),
13 resize: (input) => rpcClient.terminal.resize(input as never),15 resize: (input) => rpcClient.terminal.resize(input as never),
14 clear: (input) => rpcClient.terminal.clear(input as never),16 clear: (input) => rpcClient.terminal.clear(input as never),
15 restart: (input) => rpcClient.terminal.restart(input as never),17 restart: (input) => rpcClient.terminal.restart(input as never),
16 close: (input) => rpcClient.terminal.close(input as never),18 close: (input) => rpcClient.terminal.close(input as never),
17 onEvent: (callback) => rpcClient.terminal.onEvent(callback),19 onMetadata: (callback, options) => rpcClient.terminal.onMetadata(callback, options),
18 },20 },
19 projects: {21 projects: {
20 searchEntries: rpcClient.projects.searchEntries,22 searchEntries: rpcClient.projects.searchEntries,
...@@ -43,6 +45,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {...@@ -43,6 +45,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
43 resolvePullRequest: rpcClient.git.resolvePullRequest,45 resolvePullRequest: rpcClient.git.resolvePullRequest,
44 preparePullRequestThread: rpcClient.git.preparePullRequestThread,46 preparePullRequestThread: rpcClient.git.preparePullRequestThread,
45 },47 },
48 review: {
49 getDiffPreview: rpcClient.review.getDiffPreview,
50 },
46 orchestration: {51 orchestration: {
47 dispatchCommand: rpcClient.orchestration.dispatchCommand,52 dispatchCommand: rpcClient.orchestration.dispatchCommand,
48 getTurnDiff: rpcClient.orchestration.getTurnDiff,53 getTurnDiff: rpcClient.orchestration.getTurnDiff,
apps/web/src/environments/remote/api.test.ts deleted-248
...@@ -1,248 +0,0 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3import {
4 bootstrapRemoteBearerSession,
5 fetchRemoteEnvironmentDescriptor,
6 fetchRemoteSessionState,
7 issueRemoteWebSocketToken,
8 resolveRemoteWebSocketConnectionUrl,
9} from "./api";
10import { resolveRemotePairingTarget } from "./target";
11
12const originalFetch = globalThis.fetch;
13
14beforeEach(() => {
15 Object.defineProperty(globalThis, "window", {
16 configurable: true,
17 value: {
18 location: {
19 origin: "https://app.example.com",
20 },
21 },
22 });
23 vi.restoreAllMocks();
24});
25
26describe("remote environment api", () => {
27 it("derives backend urls and token from a pairing url", () => {
28 expect(
29 resolveRemotePairingTarget({
30 pairingUrl: "https://remote.example.com/pair#token=pairing-token",
31 }),
32 ).toEqual({
33 credential: "pairing-token",
34 httpBaseUrl: "https://remote.example.com/",
35 wsBaseUrl: "wss://remote.example.com/",
36 });
37 });
38
39 it("accepts pairing urls that still use a query token", () => {
40 expect(
41 resolveRemotePairingTarget({
42 pairingUrl: "https://remote.example.com/pair?token=pairing-token",
43 }),
44 ).toEqual({
45 credential: "pairing-token",
46 httpBaseUrl: "https://remote.example.com/",
47 wsBaseUrl: "wss://remote.example.com/",
48 });
49 });
50
51 it("derives backend urls and token from a hosted app pairing link", () => {
52 expect(
53 resolveRemotePairingTarget({
54 pairingUrl:
55 "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%3A44342%2F#token=pairing-token",
56 }),
57 ).toEqual({
58 credential: "pairing-token",
59 httpBaseUrl: "https://desktop.tailnet.ts.net:44342/",
60 wsBaseUrl: "wss://desktop.tailnet.ts.net:44342/",
61 });
62 });
63
64 it("derives backend urls from a host and pairing code", () => {
65 expect(
66 resolveRemotePairingTarget({
67 host: "https://remote.example.com",
68 pairingCode: "pairing-token",
69 }),
70 ).toEqual({
71 credential: "pairing-token",
72 httpBaseUrl: "https://remote.example.com/",
73 wsBaseUrl: "wss://remote.example.com/",
74 });
75 });
76
77 it("preserves host ports when normalizing a bare host input", () => {
78 expect(
79 resolveRemotePairingTarget({
80 host: "myserver.com:3000",
81 pairingCode: "pairing-token",
82 }),
83 ).toEqual({
84 credential: "pairing-token",
85 httpBaseUrl: "https://myserver.com:3000/",
86 wsBaseUrl: "wss://myserver.com:3000/",
87 });
88 });
89
90 it("bootstraps bearer auth against a remote backend", async () => {
91 const fetchMock = vi.fn().mockResolvedValue(
92 new Response(
93 JSON.stringify({
94 authenticated: true,
95 role: "client",
96 sessionMethod: "bearer-session-token",
97 expiresAt: "2026-05-01T12:00:00.000Z",
98 sessionToken: "bearer-token",
99 }),
100 { status: 200 },
101 ),
102 );
103 globalThis.fetch = fetchMock as typeof fetch;
104
105 await expect(
106 bootstrapRemoteBearerSession({
107 httpBaseUrl: "https://remote.example.com/",
108 credential: "pairing-token",
109 }),
110 ).resolves.toMatchObject({
111 sessionMethod: "bearer-session-token",
112 sessionToken: "bearer-token",
113 });
114
115 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/bootstrap/bearer", {
116 method: "POST",
117 headers: {
118 "content-type": "application/json",
119 },
120 body: JSON.stringify({
121 credential: "pairing-token",
122 }),
123 });
124 });
125
126 it("loads remote session state and websocket tokens over bearer auth", async () => {
127 const fetchMock = vi
128 .fn()
129 .mockResolvedValueOnce(
130 new Response(
131 JSON.stringify({
132 environmentId: "environment-remote",
133 label: "Remote environment",
134 platform: {
135 os: "linux",
136 arch: "x64",
137 },
138 serverVersion: "0.0.0-test",
139 capabilities: {
140 repositoryIdentity: true,
141 },
142 }),
143 { status: 200 },
144 ),
145 )
146 .mockResolvedValueOnce(
147 new Response(
148 JSON.stringify({
149 authenticated: true,
150 auth: {
151 policy: "remote-reachable",
152 bootstrapMethods: ["one-time-token"],
153 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
154 sessionCookieName: "t3_session",
155 },
156 role: "client",
157 sessionMethod: "bearer-session-token",
158 expiresAt: "2026-05-01T12:00:00.000Z",
159 }),
160 { status: 200 },
161 ),
162 )
163 .mockResolvedValueOnce(
164 new Response(
165 JSON.stringify({
166 token: "ws-token",
167 expiresAt: "2026-05-01T12:05:00.000Z",
168 }),
169 { status: 200 },
170 ),
171 );
172 globalThis.fetch = fetchMock as typeof fetch;
173
174 await expect(
175 fetchRemoteEnvironmentDescriptor({
176 httpBaseUrl: "https://remote.example.com/",
177 }),
178 ).resolves.toMatchObject({
179 environmentId: "environment-remote",
180 label: "Remote environment",
181 });
182
183 await expect(
184 fetchRemoteSessionState({
185 httpBaseUrl: "https://remote.example.com/",
186 bearerToken: "bearer-token",
187 }),
188 ).resolves.toMatchObject({
189 authenticated: true,
190 role: "client",
191 });
192
193 await expect(
194 issueRemoteWebSocketToken({
195 httpBaseUrl: "https://remote.example.com/",
196 bearerToken: "bearer-token",
197 }),
198 ).resolves.toMatchObject({
199 token: "ws-token",
200 });
201
202 expect(fetchMock).toHaveBeenNthCalledWith(
203 1,
204 "https://remote.example.com/.well-known/t3/environment",
205 {
206 method: "GET",
207 headers: {},
208 },
209 );
210 expect(fetchMock).toHaveBeenNthCalledWith(2, "https://remote.example.com/api/auth/session", {
211 method: "GET",
212 headers: {
213 authorization: "Bearer bearer-token",
214 },
215 });
216 expect(fetchMock).toHaveBeenNthCalledWith(3, "https://remote.example.com/api/auth/ws-token", {
217 method: "POST",
218 headers: {
219 authorization: "Bearer bearer-token",
220 },
221 });
222 });
223
224 it("mints a websocket url with a short-lived ws token", async () => {
225 const fetchMock = vi.fn().mockResolvedValue(
226 new Response(
227 JSON.stringify({
228 token: "ws-token",
229 expiresAt: "2026-05-01T12:05:00.000Z",
230 }),
231 { status: 200 },
232 ),
233 );
234 globalThis.fetch = fetchMock as typeof fetch;
235
236 await expect(
237 resolveRemoteWebSocketConnectionUrl({
238 wsBaseUrl: "wss://remote.example.com/",
239 httpBaseUrl: "https://remote.example.com/",
240 bearerToken: "bearer-token",
241 }),
242 ).resolves.toBe("wss://remote.example.com/?wsToken=ws-token");
243 });
244});
245
246afterEach(() => {
247 globalThis.fetch = originalFetch;
248});
apps/web/src/environments/remote/api.ts deleted-149
...@@ -1,149 +0,0 @@
1import type {
2 AuthBearerBootstrapResult,
3 AuthSessionState,
4 AuthWebSocketTokenResult,
5 ExecutionEnvironmentDescriptor,
6} from "@t3tools/contracts";
7
8class RemoteEnvironmentAuthHttpError extends Error {
9 readonly status: number;
10
11 constructor(message: string, status: number) {
12 super(message);
13 this.name = "RemoteEnvironmentAuthHttpError";
14 this.status = status;
15 }
16}
17
18export function isRemoteEnvironmentAuthHttpError(
19 error: unknown,
20): error is RemoteEnvironmentAuthHttpError {
21 return error instanceof RemoteEnvironmentAuthHttpError;
22}
23
24function remoteEndpointUrl(httpBaseUrl: string, pathname: string): string {
25 const url = new URL(httpBaseUrl);
26 url.pathname = pathname;
27 url.search = "";
28 url.hash = "";
29 return url.toString();
30}
31
32async function readRemoteAuthErrorMessage(
33 response: Response,
34 fallbackMessage: string,
35): Promise<string> {
36 const text = await response.text();
37 if (!text) {
38 return fallbackMessage;
39 }
40
41 try {
42 const parsed = JSON.parse(text) as { readonly error?: string };
43 if (typeof parsed.error === "string" && parsed.error.length > 0) {
44 return parsed.error;
45 }
46 } catch {
47 // Fall back to raw text below.
48 }
49
50 return text;
51}
52
53async function fetchRemoteJson<T>(input: {
54 readonly httpBaseUrl: string;
55 readonly pathname: string;
56 readonly method?: "GET" | "POST";
57 readonly bearerToken?: string;
58 readonly body?: unknown;
59}): Promise<T> {
60 const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname);
61 let response: Response;
62 try {
63 response = await fetch(requestUrl, {
64 method: input.method ?? "GET",
65 headers: {
66 ...(input.body !== undefined ? { "content-type": "application/json" } : {}),
67 ...(input.bearerToken ? { authorization: `Bearer ${input.bearerToken}` } : {}),
68 },
69 ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
70 });
71 } catch (error) {
72 throw new Error(
73 `Failed to fetch remote auth endpoint ${requestUrl} (${(error as Error).message}).`,
74 { cause: error },
75 );
76 }
77
78 if (!response.ok) {
79 throw new RemoteEnvironmentAuthHttpError(
80 await readRemoteAuthErrorMessage(
81 response,
82 `Remote auth request failed (${response.status}).`,
83 ),
84 response.status,
85 );
86 }
87
88 return (await response.json()) as T;
89}
90
91export async function bootstrapRemoteBearerSession(input: {
92 readonly httpBaseUrl: string;
93 readonly credential: string;
94}): Promise<AuthBearerBootstrapResult> {
95 return fetchRemoteJson<AuthBearerBootstrapResult>({
96 httpBaseUrl: input.httpBaseUrl,
97 pathname: "/api/auth/bootstrap/bearer",
98 method: "POST",
99 body: {
100 credential: input.credential,
101 },
102 });
103}
104
105export async function fetchRemoteSessionState(input: {
106 readonly httpBaseUrl: string;
107 readonly bearerToken: string;
108}): Promise<AuthSessionState> {
109 return fetchRemoteJson<AuthSessionState>({
110 httpBaseUrl: input.httpBaseUrl,
111 pathname: "/api/auth/session",
112 bearerToken: input.bearerToken,
113 });
114}
115
116export async function fetchRemoteEnvironmentDescriptor(input: {
117 readonly httpBaseUrl: string;
118}): Promise<ExecutionEnvironmentDescriptor> {
119 return fetchRemoteJson<ExecutionEnvironmentDescriptor>({
120 httpBaseUrl: input.httpBaseUrl,
121 pathname: "/.well-known/t3/environment",
122 });
123}
124
125export async function issueRemoteWebSocketToken(input: {
126 readonly httpBaseUrl: string;
127 readonly bearerToken: string;
128}): Promise<AuthWebSocketTokenResult> {
129 return fetchRemoteJson<AuthWebSocketTokenResult>({
130 httpBaseUrl: input.httpBaseUrl,
131 pathname: "/api/auth/ws-token",
132 method: "POST",
133 bearerToken: input.bearerToken,
134 });
135}
136
137export async function resolveRemoteWebSocketConnectionUrl(input: {
138 readonly wsBaseUrl: string;
139 readonly httpBaseUrl: string;
140 readonly bearerToken: string;
141}): Promise<string> {
142 const issued = await issueRemoteWebSocketToken({
143 httpBaseUrl: input.httpBaseUrl,
144 bearerToken: input.bearerToken,
145 });
146 const url = new URL(input.wsBaseUrl, window.location.origin);
147 url.searchParams.set("wsToken", issued.token);
148 return url.toString();
149}
apps/web/src/environments/remote/target.ts deleted-97
...@@ -1,97 +0,0 @@
1import { getPairingTokenFromUrl } from "../../pairingUrl";
2import { readHostedPairingRequest } from "../../hostedPairing";
3
4export interface ResolvedRemotePairingTarget {
5 readonly credential: string;
6 readonly httpBaseUrl: string;
7 readonly wsBaseUrl: string;
8}
9
10function normalizeRemoteBaseUrl(rawValue: string): URL {
11 const trimmed = rawValue.trim();
12 if (!trimmed) {
13 throw new Error("Enter a backend URL.");
14 }
15
16 const normalizedInput =
17 /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//")
18 ? trimmed
19 : `https://${trimmed}`;
20 const url = new URL(normalizedInput, window.location.origin);
21 url.pathname = "/";
22 url.search = "";
23 url.hash = "";
24 return url;
25}
26
27function toHttpBaseUrl(url: URL): string {
28 const next = new URL(url.toString());
29 if (next.protocol === "ws:") {
30 next.protocol = "http:";
31 } else if (next.protocol === "wss:") {
32 next.protocol = "https:";
33 }
34 next.pathname = "/";
35 next.search = "";
36 next.hash = "";
37 return next.toString();
38}
39
40function toWsBaseUrl(url: URL): string {
41 const next = new URL(url.toString());
42 if (next.protocol === "http:") {
43 next.protocol = "ws:";
44 } else if (next.protocol === "https:") {
45 next.protocol = "wss:";
46 }
47 next.pathname = "/";
48 next.search = "";
49 next.hash = "";
50 return next.toString();
51}
52
53export function resolveRemotePairingTarget(input: {
54 readonly pairingUrl?: string;
55 readonly host?: string;
56 readonly pairingCode?: string;
57}): ResolvedRemotePairingTarget {
58 const pairingUrl = input.pairingUrl?.trim() ?? "";
59 if (pairingUrl.length > 0) {
60 const url = new URL(pairingUrl, window.location.origin);
61 const hostedPairingRequest = readHostedPairingRequest(url);
62 if (hostedPairingRequest) {
63 const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host);
64 return {
65 credential: hostedPairingRequest.token,
66 httpBaseUrl: toHttpBaseUrl(hostedBackendUrl),
67 wsBaseUrl: toWsBaseUrl(hostedBackendUrl),
68 };
69 }
70
71 const credential = getPairingTokenFromUrl(url) ?? "";
72 if (!credential) {
73 throw new Error("Pairing URL is missing its token.");
74 }
75 return {
76 credential,
77 httpBaseUrl: toHttpBaseUrl(url),
78 wsBaseUrl: toWsBaseUrl(url),
79 };
80 }
81
82 const host = input.host?.trim() ?? "";
83 const pairingCode = input.pairingCode?.trim() ?? "";
84 if (!host) {
85 throw new Error("Enter a backend URL.");
86 }
87 if (!pairingCode) {
88 throw new Error("Enter a pairing code.");
89 }
90
91 const normalizedHost = normalizeRemoteBaseUrl(host);
92 return {
93 credential: pairingCode,
94 httpBaseUrl: toHttpBaseUrl(normalizedHost),
95 wsBaseUrl: toWsBaseUrl(normalizedHost),
96 };
97}
apps/web/src/environments/runtime/connection.test.ts+46-20
...@@ -2,12 +2,11 @@ import { EnvironmentId } from "@t3tools/contracts";...@@ -2,12 +2,11 @@ import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vitest";2import { describe, expect, it, vi } from "vitest";
33
4import { createEnvironmentConnection } from "./connection";4import { createEnvironmentConnection } from "./connection";
5import type { WsRpcClient } from "~/rpc/wsRpcClient";5import type { WsRpcClient } from "@t3tools/client-runtime";
66
7function createTestClient() {7function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) {
8 const lifecycleListeners = new Set<(event: any) => void>();8 const lifecycleListeners = new Set<(event: any) => void>();
9 const configListeners = new Set<(event: any) => void>();9 const configListeners = new Set<(event: any) => void>();
10 const terminalListeners = new Set<(event: any) => void>();
11 const shellListeners = new Set<(event: any) => void>();10 const shellListeners = new Set<(event: any) => void>();
12 let shellResubscribe: (() => void) | undefined;11 let shellResubscribe: (() => void) | undefined;
1312
...@@ -44,17 +43,19 @@ function createTestClient() {...@@ -44,17 +43,19 @@ function createTestClient() {
44 (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => {43 (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => {
45 shellListeners.add(listener);44 shellListeners.add(listener);
46 shellResubscribe = options?.onResubscribe;45 shellResubscribe = options?.onResubscribe;
47 queueMicrotask(() => {46 if (config?.emitInitialSnapshot !== false) {
48 listener({47 queueMicrotask(() => {
49 kind: "snapshot",48 listener({
50 snapshot: {49 kind: "snapshot",
51 snapshotSequence: 1,50 snapshot: {
52 projects: [],51 snapshotSequence: 1,
53 threads: [],52 projects: [],
54 updatedAt: "2026-04-12T00:00:00.000Z",53 threads: [],
55 },54 updatedAt: "2026-04-12T00:00:00.000Z",
55 },
56 });
56 });57 });
57 });58 }
58 return () => {59 return () => {
59 shellListeners.delete(listener);60 shellListeners.delete(listener);
60 if (shellResubscribe === options?.onResubscribe) {61 if (shellResubscribe === options?.onResubscribe) {
...@@ -67,15 +68,14 @@ function createTestClient() {...@@ -67,15 +68,14 @@ function createTestClient() {
67 },68 },
68 terminal: {69 terminal: {
69 open: vi.fn(async () => undefined),70 open: vi.fn(async () => undefined),
71 attach: vi.fn(() => () => undefined),
70 write: vi.fn(async () => undefined),72 write: vi.fn(async () => undefined),
71 resize: vi.fn(async () => undefined),73 resize: vi.fn(async () => undefined),
72 clear: vi.fn(async () => undefined),74 clear: vi.fn(async () => undefined),
73 restart: vi.fn(async () => undefined),75 restart: vi.fn(async () => undefined),
74 close: vi.fn(async () => undefined),76 close: vi.fn(async () => undefined),
75 onEvent: (listener: (event: any) => void) => {77 onEvent: vi.fn(() => () => undefined),
76 terminalListeners.add(listener);78 onMetadata: vi.fn(() => () => undefined),
77 return () => terminalListeners.delete(listener);
78 },
79 },79 },
80 projects: {80 projects: {
81 searchEntries: vi.fn(async () => []),81 searchEntries: vi.fn(async () => []),
...@@ -89,6 +89,9 @@ function createTestClient() {...@@ -89,6 +89,9 @@ function createTestClient() {
89 resolvePullRequest: vi.fn(async () => undefined),89 resolvePullRequest: vi.fn(async () => undefined),
90 preparePullRequestThread: vi.fn(async () => undefined),90 preparePullRequestThread: vi.fn(async () => undefined),
91 },91 },
92 review: {
93 getDiffPreview: vi.fn(async () => undefined),
94 },
92 } as unknown as WsRpcClient;95 } as unknown as WsRpcClient;
9396
94 return {97 return {
...@@ -154,7 +157,6 @@ describe("createEnvironmentConnection", () => {...@@ -154,7 +157,6 @@ describe("createEnvironmentConnection", () => {
154 client,157 client,
155 applyShellEvent: vi.fn(),158 applyShellEvent: vi.fn(),
156 syncShellSnapshot,159 syncShellSnapshot,
157 applyTerminalEvent: vi.fn(),
158 });160 });
159161
160 await connection.ensureBootstrapped();162 await connection.ensureBootstrapped();
...@@ -186,7 +188,6 @@ describe("createEnvironmentConnection", () => {...@@ -186,7 +188,6 @@ describe("createEnvironmentConnection", () => {
186 client,188 client,
187 applyShellEvent: vi.fn(),189 applyShellEvent: vi.fn(),
188 syncShellSnapshot: vi.fn(),190 syncShellSnapshot: vi.fn(),
189 applyTerminalEvent: vi.fn(),
190 });191 });
191192
192 expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow(193 expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow(
...@@ -216,7 +217,6 @@ describe("createEnvironmentConnection", () => {...@@ -216,7 +217,6 @@ describe("createEnvironmentConnection", () => {
216 client,217 client,
217 applyShellEvent: vi.fn(),218 applyShellEvent: vi.fn(),
218 syncShellSnapshot,219 syncShellSnapshot,
219 applyTerminalEvent: vi.fn(),
220 });220 });
221221
222 await connection.ensureBootstrapped();222 await connection.ensureBootstrapped();
...@@ -266,4 +266,30 @@ describe("createEnvironmentConnection", () => {...@@ -266,4 +266,30 @@ describe("createEnvironmentConnection", () => {
266266
267 await connection.dispose();267 await connection.dispose();
268 });268 });
269
270 it("rejects bootstrap waits when a pending connection is disposed", async () => {
271 const environmentId = EnvironmentId.make("env-1");
272 const { client } = createTestClient({ emitInitialSnapshot: false });
273 const connection = createEnvironmentConnection({
274 kind: "saved",
275 knownEnvironment: {
276 id: "env-1",
277 label: "Remote env",
278 source: "manual",
279 target: {
280 httpBaseUrl: "http://example.test",
281 wsBaseUrl: "ws://example.test",
282 },
283 environmentId,
284 },
285 client,
286 applyShellEvent: vi.fn(),
287 syncShellSnapshot: vi.fn(),
288 });
289 const pendingBootstrap = connection.ensureBootstrapped();
290
291 await connection.dispose();
292
293 await expect(pendingBootstrap).rejects.toThrow("was disposed before it finished bootstrapping");
294 });
269});295});
apps/web/src/environments/runtime/connection.ts+7-182
...@@ -1,182 +1,7 @@...@@ -1,182 +1,7 @@
1import type {1export {
2 EnvironmentId,2 createEnvironmentConnection,
3 OrchestrationShellSnapshot,3 createEnvironmentConnectionAttemptRegistry,
4 OrchestrationShellStreamEvent,4 EnvironmentConnectionAttemptCancelledError,
5 ServerConfig,5 EnvironmentConnectionDisposedError,
6 ServerLifecycleWelcomePayload,6 type EnvironmentConnection,
7 TerminalEvent,7} from "@t3tools/client-runtime";
8} from "@t3tools/contracts";
9import type { KnownEnvironment } from "@t3tools/client-runtime";
10
11import type { WsRpcClient } from "~/rpc/wsRpcClient";
12
13export interface EnvironmentConnection {
14 readonly kind: "primary" | "saved";
15 readonly environmentId: EnvironmentId;
16 readonly knownEnvironment: KnownEnvironment;
17 readonly client: WsRpcClient;
18 readonly ensureBootstrapped: () => Promise<void>;
19 readonly reconnect: () => Promise<void>;
20 readonly dispose: () => Promise<void>;
21}
22
23interface OrchestrationHandlers {
24 readonly applyShellEvent: (
25 event: OrchestrationShellStreamEvent,
26 environmentId: EnvironmentId,
27 ) => void;
28 readonly syncShellSnapshot: (
29 snapshot: OrchestrationShellSnapshot,
30 environmentId: EnvironmentId,
31 ) => void;
32 readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void;
33}
34
35interface EnvironmentConnectionInput extends OrchestrationHandlers {
36 readonly kind: "primary" | "saved";
37 readonly knownEnvironment: KnownEnvironment;
38 readonly client: WsRpcClient;
39 readonly refreshMetadata?: () => Promise<void>;
40 readonly onConfigSnapshot?: (config: ServerConfig) => void;
41 readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void;
42}
43
44function createBootstrapGate() {
45 let resolve: (() => void) | null = null;
46 let reject: ((error: unknown) => void) | null = null;
47 let promise = new Promise<void>((nextResolve, nextReject) => {
48 resolve = nextResolve;
49 reject = nextReject;
50 });
51
52 return {
53 wait: () => promise,
54 resolve: () => {
55 resolve?.();
56 resolve = null;
57 reject = null;
58 },
59 reject: (error: unknown) => {
60 reject?.(error);
61 resolve = null;
62 reject = null;
63 },
64 reset: () => {
65 promise = new Promise<void>((nextResolve, nextReject) => {
66 resolve = nextResolve;
67 reject = nextReject;
68 });
69 },
70 };
71}
72
73export function createEnvironmentConnection(
74 input: EnvironmentConnectionInput,
75): EnvironmentConnection {
76 const environmentId = input.knownEnvironment.environmentId;
77
78 if (!environmentId) {
79 throw new Error(
80 `Known environment ${input.knownEnvironment.label} is missing its environmentId.`,
81 );
82 }
83
84 let disposed = false;
85 const bootstrapGate = createBootstrapGate();
86 const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined;
87 const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined;
88
89 const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => {
90 if (environmentId !== nextEnvironmentId) {
91 throw new Error(
92 `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`,
93 );
94 }
95 };
96
97 const unsubLifecycle = shouldObserveLifecycle
98 ? input.client.server.subscribeLifecycle(
99 (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => {
100 if (event.type !== "welcome") {
101 return;
102 }
103 observeEnvironmentIdentity(
104 event.payload.environment.environmentId,
105 "server lifecycle welcome",
106 );
107 input.onWelcome?.(event.payload);
108 },
109 )
110 : () => undefined;
111
112 const unsubConfig = shouldObserveConfig
113 ? input.client.server.subscribeConfig(
114 (event: Parameters<Parameters<WsRpcClient["server"]["subscribeConfig"]>[0]>[0]) => {
115 if (event.type !== "snapshot") {
116 return;
117 }
118 observeEnvironmentIdentity(
119 event.config.environment.environmentId,
120 "server config snapshot",
121 );
122 input.onConfigSnapshot?.(event.config);
123 },
124 )
125 : () => undefined;
126
127 const unsubShell = input.client.orchestration.subscribeShell(
128 (item: Parameters<Parameters<WsRpcClient["orchestration"]["subscribeShell"]>[0]>[0]) => {
129 if (item.kind === "snapshot") {
130 input.syncShellSnapshot(item.snapshot, environmentId);
131 bootstrapGate.resolve();
132 return;
133 }
134 input.applyShellEvent(item, environmentId);
135 },
136 {
137 onResubscribe: () => {
138 if (disposed) {
139 return;
140 }
141 bootstrapGate.reset();
142 },
143 },
144 );
145
146 const unsubTerminalEvent = input.client.terminal.onEvent(
147 (event: Parameters<Parameters<WsRpcClient["terminal"]["onEvent"]>[0]>[0]) => {
148 input.applyTerminalEvent(event, environmentId);
149 },
150 );
151
152 const cleanup = () => {
153 disposed = true;
154 unsubShell();
155 unsubTerminalEvent();
156 unsubLifecycle();
157 unsubConfig();
158 };
159
160 return {
161 kind: input.kind,
162 environmentId,
163 knownEnvironment: input.knownEnvironment,
164 client: input.client,
165 ensureBootstrapped: () => bootstrapGate.wait(),
166 reconnect: async () => {
167 bootstrapGate.reset();
168 try {
169 await input.client.reconnect();
170 await input.refreshMetadata?.();
171 await bootstrapGate.wait();
172 } catch (error) {
173 bootstrapGate.reject(error);
174 throw error;
175 }
176 },
177 dispose: async () => {
178 cleanup();
179 await input.client.dispose();
180 },
181 };
182}
apps/web/src/environments/runtime/index.ts+1
...@@ -27,4 +27,5 @@ export {...@@ -27,4 +27,5 @@ export {
27 resetEnvironmentServiceForTests,27 resetEnvironmentServiceForTests,
28 startEnvironmentConnectionService,28 startEnvironmentConnectionService,
29 subscribeEnvironmentConnections,29 subscribeEnvironmentConnections,
30 subscribeProviderInvalidations,
30} from "./service";31} from "./service";
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+36-19
...@@ -9,6 +9,7 @@ const mockBootstrapRemoteBearerSession = vi.fn();...@@ -9,6 +9,7 @@ const mockBootstrapRemoteBearerSession = vi.fn();
9const mockFetchRemoteSessionState = vi.fn();9const mockFetchRemoteSessionState = vi.fn();
10const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false);10const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false);
11const mockResolveRemoteWebSocketConnectionUrl = vi.fn();11const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
12const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);
12const mockBootstrapSshBearerSession = vi.fn();13const mockBootstrapSshBearerSession = vi.fn();
13const mockFetchSshSessionState = vi.fn();14const mockFetchSshSessionState = vi.fn();
14const mockPersistSavedEnvironmentRecord = vi.fn();15const mockPersistSavedEnvironmentRecord = vi.fn();
...@@ -56,16 +57,15 @@ const mockClientGetConfig = vi.fn(async () => ({...@@ -56,16 +57,15 @@ const mockClientGetConfig = vi.fn(async () => ({
56 },57 },
57}));58}));
5859
59vi.mock("../remote/target", () => ({60vi.mock("@t3tools/shared/remote", async (importOriginal) => ({
61 ...(await importOriginal<typeof import("@t3tools/shared/remote")>()),
60 resolveRemotePairingTarget: mockResolveRemotePairingTarget,62 resolveRemotePairingTarget: mockResolveRemotePairingTarget,
61}));63}));
6264
63vi.mock("../remote/api", () => ({65vi.mock("../../lib/runtime", () => ({
64 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,66 remoteHttpRuntime: {
65 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,67 runPromise: mockRemoteHttpRunPromise,
66 fetchRemoteSessionState: mockFetchRemoteSessionState,68 },
67 isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError,
68 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
69}));69}));
7070
71vi.mock("~/localApi", () => ({71vi.mock("~/localApi", () => ({
...@@ -105,20 +105,33 @@ vi.mock("./catalog", () => ({...@@ -105,20 +105,33 @@ vi.mock("./catalog", () => ({
105 writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken,105 writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken,
106}));106}));
107107
108vi.mock("./connection", () => ({108vi.mock("./connection", async (importOriginal) => ({
109 ...(await importOriginal<typeof import("./connection")>()),
109 createEnvironmentConnection: mockCreateEnvironmentConnection,110 createEnvironmentConnection: mockCreateEnvironmentConnection,
110}));111}));
111112
112vi.mock("../../rpc/wsRpcClient", () => ({113vi.mock("@t3tools/client-runtime", async (importOriginal) => {
113 createWsRpcClient: vi.fn(() => ({114 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();
114 server: {115 return {
115 getConfig: mockClientGetConfig,116 ...actual,
116 },117 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,
117 orchestration: {118 createWsRpcClient: vi.fn(() => ({
118 subscribeThread: vi.fn(() => () => {}),119 server: {
119 },120 getConfig: mockClientGetConfig,
120 })),121 },
121}));122 terminal: {
123 onMetadata: vi.fn(() => () => undefined),
124 },
125 orchestration: {
126 subscribeThread: vi.fn(() => () => {}),
127 },
128 })),
129 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
130 fetchRemoteSessionState: mockFetchRemoteSessionState,
131 isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError,
132 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
133 };
134});
122135
123vi.mock("../../rpc/wsTransport", () => ({136vi.mock("../../rpc/wsTransport", () => ({
124 WsTransport: vi.fn(),137 WsTransport: vi.fn(),
...@@ -501,7 +514,11 @@ describe("addSavedEnvironment", () => {...@@ -501,7 +514,11 @@ describe("addSavedEnvironment", () => {
501 knownEnvironment: {514 knownEnvironment: {
502 environmentId: EnvironmentId.make("environment-1"),515 environmentId: EnvironmentId.make("environment-1"),
503 },516 },
504 client: {},517 client: {
518 terminal: {
519 onMetadata: vi.fn(() => () => undefined),
520 },
521 },
505 ensureBootstrapped: async () => undefined,522 ensureBootstrapped: async () => undefined,
506 reconnect: vi.fn(async () => {523 reconnect: vi.fn(async () => {
507 throw new Error("socket closed");524 throw new Error("socket closed");
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+18-22
...@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";...@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5const mockCreateEnvironmentConnection = vi.fn();5const mockCreateEnvironmentConnection = vi.fn();
6const mockCreateWsRpcClient = vi.fn();6const mockCreateWsRpcClient = vi.fn();
7const mockFetchRemoteSessionState = vi.fn();7const mockFetchRemoteSessionState = vi.fn();
8const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test");
9const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);
8const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();10const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
9const mockListSavedEnvironmentRecords = vi.fn();11const mockListSavedEnvironmentRecords = vi.fn();
10const mockSavedEnvironmentRegistrySubscribe = vi.fn();12const mockSavedEnvironmentRegistrySubscribe = vi.fn();
...@@ -28,11 +30,10 @@ vi.mock("../primary", () => ({...@@ -28,11 +30,10 @@ vi.mock("../primary", () => ({
28 })),30 })),
29}));31}));
3032
31vi.mock("../remote/api", () => ({33vi.mock("../../lib/runtime", () => ({
32 bootstrapRemoteBearerSession: vi.fn(),34 remoteHttpRuntime: {
33 fetchRemoteEnvironmentDescriptor: vi.fn(),35 runPromise: mockRemoteHttpRunPromise,
34 fetchRemoteSessionState: mockFetchRemoteSessionState,36 },
35 resolveRemoteWebSocketConnectionUrl: vi.fn(() => "ws://remote.example.test"),
36}));37}));
3738
38vi.mock("./catalog", () => ({39vi.mock("./catalog", () => ({
...@@ -62,13 +63,20 @@ vi.mock("./catalog", () => ({...@@ -62,13 +63,20 @@ vi.mock("./catalog", () => ({
62 writeSavedEnvironmentBearerToken: vi.fn(),63 writeSavedEnvironmentBearerToken: vi.fn(),
63}));64}));
6465
65vi.mock("./connection", () => ({66vi.mock("./connection", async (importOriginal) => ({
67 ...(await importOriginal<typeof import("./connection")>()),
66 createEnvironmentConnection: mockCreateEnvironmentConnection,68 createEnvironmentConnection: mockCreateEnvironmentConnection,
67}));69}));
6870
69vi.mock("../../rpc/wsRpcClient", () => ({71vi.mock("@t3tools/client-runtime", async (importOriginal) => {
70 createWsRpcClient: mockCreateWsRpcClient,72 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();
71}));73 return {
74 ...actual,
75 createWsRpcClient: mockCreateWsRpcClient,
76 fetchRemoteSessionState: mockFetchRemoteSessionState,
77 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
78 };
79});
7280
73vi.mock("../../rpc/wsTransport", () => ({81vi.mock("../../rpc/wsTransport", () => ({
74 WsTransport: MockWsTransport,82 WsTransport: MockWsTransport,
...@@ -104,18 +112,6 @@ vi.mock("~/orchestrationEventEffects", () => ({...@@ -104,18 +112,6 @@ vi.mock("~/orchestrationEventEffects", () => ({
104 })),112 })),
105}));113}));
106114
107vi.mock("~/lib/projectReactQuery", () => ({
108 projectQueryKeys: {
109 all: ["projects"],
110 },
111}));
112
113vi.mock("~/lib/providerReactQuery", () => ({
114 providerQueryKeys: {
115 all: ["providers"],
116 },
117}));
118
119vi.mock("~/store", () => ({115vi.mock("~/store", () => ({
120 useStore: {116 useStore: {
121 getState: () => ({117 getState: () => ({
...@@ -192,7 +188,7 @@ function createClient() {...@@ -192,7 +188,7 @@ function createClient() {
192 clear: vi.fn(async () => undefined),188 clear: vi.fn(async () => undefined),
193 restart: vi.fn(async () => undefined),189 restart: vi.fn(async () => undefined),
194 close: vi.fn(async () => undefined),190 close: vi.fn(async () => undefined),
195 onEvent: vi.fn(() => () => undefined),191 onMetadata: vi.fn(() => () => undefined),
196 },192 },
197 projects: {193 projects: {
198 searchEntries: vi.fn(async () => []),194 searchEntries: vi.fn(async () => []),
apps/web/src/environments/runtime/service.test.ts deleted-148
...@@ -1,148 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 shouldApplyProjectionEvent,
5 shouldApplyProjectionSnapshot,
6 shouldApplyTerminalEvent,
7} from "./service";
8
9describe("shouldApplyTerminalEvent", () => {
10 it("applies terminal events for draft-only threads", () => {
11 expect(
12 shouldApplyTerminalEvent({
13 serverThreadArchivedAt: undefined,
14 hasDraftThread: true,
15 }),
16 ).toBe(true);
17 });
18
19 it("drops terminal events for unknown threads", () => {
20 expect(
21 shouldApplyTerminalEvent({
22 serverThreadArchivedAt: undefined,
23 hasDraftThread: false,
24 }),
25 ).toBe(false);
26 });
27
28 it("drops terminal events for archived server threads even if a draft exists", () => {
29 expect(
30 shouldApplyTerminalEvent({
31 serverThreadArchivedAt: "2026-04-09T00:00:00.000Z",
32 hasDraftThread: true,
33 }),
34 ).toBe(false);
35 });
36
37 it("applies terminal events for active server threads", () => {
38 expect(
39 shouldApplyTerminalEvent({
40 serverThreadArchivedAt: null,
41 hasDraftThread: false,
42 }),
43 ).toBe(true);
44 });
45});
46
47describe("shouldApplyProjectionSnapshot", () => {
48 it("accepts the first snapshot for an environment", () => {
49 expect(
50 shouldApplyProjectionSnapshot({
51 current: null,
52 next: {
53 snapshotSequence: 1,
54 updatedAt: "2026-04-22T10:00:00.000Z",
55 },
56 }),
57 ).toBe(true);
58 });
59
60 it("drops snapshots with an older sequence", () => {
61 expect(
62 shouldApplyProjectionSnapshot({
63 current: {
64 sequence: 5,
65 updatedAt: "2026-04-22T10:05:00.000Z",
66 },
67 next: {
68 snapshotSequence: 4,
69 updatedAt: "2026-04-22T10:06:00.000Z",
70 },
71 }),
72 ).toBe(false);
73 });
74
75 it("drops snapshots with the same sequence and older timestamp", () => {
76 expect(
77 shouldApplyProjectionSnapshot({
78 current: {
79 sequence: 5,
80 updatedAt: "2026-04-22T10:05:00.000Z",
81 },
82 next: {
83 snapshotSequence: 5,
84 updatedAt: "2026-04-22T10:04:59.000Z",
85 },
86 }),
87 ).toBe(false);
88 });
89
90 it("accepts snapshots with the same sequence and a newer timestamp", () => {
91 expect(
92 shouldApplyProjectionSnapshot({
93 current: {
94 sequence: 5,
95 updatedAt: "2026-04-22T10:05:00.000Z",
96 },
97 next: {
98 snapshotSequence: 5,
99 updatedAt: "2026-04-22T10:05:01.000Z",
100 },
101 }),
102 ).toBe(true);
103 });
104});
105
106describe("shouldApplyProjectionEvent", () => {
107 it("accepts the first event for an environment", () => {
108 expect(
109 shouldApplyProjectionEvent({
110 current: null,
111 sequence: 1,
112 }),
113 ).toBe(true);
114 });
115
116 it("drops stale or duplicate events", () => {
117 expect(
118 shouldApplyProjectionEvent({
119 current: {
120 sequence: 5,
121 updatedAt: "2026-04-22T10:05:00.000Z",
122 },
123 sequence: 5,
124 }),
125 ).toBe(false);
126 expect(
127 shouldApplyProjectionEvent({
128 current: {
129 sequence: 5,
130 updatedAt: "2026-04-22T10:05:00.000Z",
131 },
132 sequence: 4,
133 }),
134 ).toBe(false);
135 });
136
137 it("accepts newer events", () => {
138 expect(
139 shouldApplyProjectionEvent({
140 current: {
141 sequence: 5,
142 updatedAt: "2026-04-22T10:05:00.000Z",
143 },
144 sequence: 6,
145 }),
146 ).toBe(true);
147 });
148});
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+136-12
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { QueryClient } from "@tanstack/react-query";1import { QueryClient } from "@tanstack/react-query";
2import type { WsRpcClient } from "@t3tools/client-runtime";
2import {3import {
3 EnvironmentId,4 EnvironmentId,
4 ProjectId,5 ProjectId,
...@@ -20,6 +21,8 @@ const mockReadSavedEnvironmentBearerToken = vi.fn();...@@ -20,6 +21,8 @@ const mockReadSavedEnvironmentBearerToken = vi.fn();
20const mockSavedEnvironmentRegistrySubscribe = vi.fn();21const mockSavedEnvironmentRegistrySubscribe = vi.fn();
21const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());22const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());
22const mockFetchRemoteSessionState = vi.fn();23const mockFetchRemoteSessionState = vi.fn();
24const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/ws");
25const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);
23const mockConnectionReconnects: Array<ReturnType<typeof vi.fn>> = [];26const mockConnectionReconnects: Array<ReturnType<typeof vi.fn>> = [];
24let savedEnvironmentRegistryListener: (() => void) | null = null;27let savedEnvironmentRegistryListener: (() => void) | null = null;
2528
...@@ -31,12 +34,10 @@ vi.mock("../primary", () => ({...@@ -31,12 +34,10 @@ vi.mock("../primary", () => ({
31 getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment,34 getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment,
32}));35}));
3336
34vi.mock("../remote/api", () => ({37vi.mock("../../lib/runtime", () => ({
35 bootstrapRemoteBearerSession: vi.fn(),38 remoteHttpRuntime: {
36 fetchRemoteEnvironmentDescriptor: vi.fn(),39 runPromise: mockRemoteHttpRunPromise,
37 fetchRemoteSessionState: mockFetchRemoteSessionState,40 },
38 isRemoteEnvironmentAuthHttpError: vi.fn(() => false),
39 resolveRemoteWebSocketConnectionUrl: vi.fn(async () => "ws://remote.example.test/ws"),
40}));41}));
4142
42vi.mock("./catalog", () => ({43vi.mock("./catalog", () => ({
...@@ -66,13 +67,96 @@ vi.mock("./catalog", () => ({...@@ -66,13 +67,96 @@ vi.mock("./catalog", () => ({
66 writeSavedEnvironmentBearerToken: vi.fn(),67 writeSavedEnvironmentBearerToken: vi.fn(),
67}));68}));
6869
69vi.mock("./connection", () => ({70vi.mock("./connection", async (importOriginal) => ({
71 ...(await importOriginal<typeof import("./connection")>()),
70 createEnvironmentConnection: mockCreateEnvironmentConnection,72 createEnvironmentConnection: mockCreateEnvironmentConnection,
71}));73}));
7274
73vi.mock("../../rpc/wsRpcClient", () => ({75vi.mock("@t3tools/client-runtime", async (importOriginal) => {
74 createWsRpcClient: mockCreateWsRpcClient,76 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();
75}));77 const stubWsClient: WsRpcClient = {
78 dispose: async () => undefined,
79 reconnect: async () => undefined,
80 isHeartbeatFresh: () => false,
81 orchestration: {
82 dispatchCommand: vi.fn(),
83 getTurnDiff: vi.fn(),
84 getFullThreadDiff: vi.fn(),
85 getArchivedShellSnapshot: vi.fn(),
86 subscribeShell: vi.fn(() => () => undefined),
87 subscribeThread: mockSubscribeThread,
88 },
89 terminal: {
90 open: vi.fn(),
91 attach: vi.fn(() => () => undefined),
92 write: vi.fn(),
93 resize: vi.fn(),
94 clear: vi.fn(),
95 restart: vi.fn(),
96 close: vi.fn(),
97 onEvent: vi.fn(() => () => undefined),
98 onMetadata: vi.fn(() => () => undefined),
99 },
100 projects: {
101 searchEntries: vi.fn(),
102 writeFile: vi.fn(),
103 },
104 filesystem: {
105 browse: vi.fn(),
106 },
107 sourceControl: {
108 lookupRepository: vi.fn(),
109 cloneRepository: vi.fn(),
110 publishRepository: vi.fn(),
111 },
112 shell: {
113 openInEditor: vi.fn(),
114 },
115 vcs: {
116 pull: vi.fn(),
117 refreshStatus: vi.fn(),
118 onStatus: vi.fn(() => () => undefined),
119 listRefs: vi.fn(),
120 createWorktree: vi.fn(),
121 removeWorktree: vi.fn(),
122 createRef: vi.fn(),
123 switchRef: vi.fn(),
124 init: vi.fn(),
125 },
126 git: {
127 runStackedAction: vi.fn(),
128 resolvePullRequest: vi.fn(),
129 preparePullRequestThread: vi.fn(),
130 },
131 review: {
132 getDiffPreview: vi.fn(),
133 },
134 server: {
135 getConfig: vi.fn(),
136 refreshProviders: vi.fn(),
137 discoverSourceControl: vi.fn(),
138 updateProvider: vi.fn(),
139 upsertKeybinding: vi.fn(),
140 removeKeybinding: vi.fn(),
141 getSettings: vi.fn(),
142 updateSettings: vi.fn(),
143 subscribeConfig: vi.fn(() => () => undefined),
144 subscribeLifecycle: vi.fn(() => () => undefined),
145 subscribeAuthAccess: vi.fn(() => () => undefined),
146 getTraceDiagnostics: vi.fn(),
147 getProcessDiagnostics: vi.fn(),
148 getProcessResourceHistory: vi.fn(),
149 signalProcess: vi.fn(),
150 },
151 };
152 return {
153 ...actual,
154 createWsRpcClient: vi.fn(() => stubWsClient),
155 fetchRemoteSessionState: mockFetchRemoteSessionState,
156 isRemoteEnvironmentAuthHttpError: vi.fn(() => false),
157 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
158 };
159});
76160
77vi.mock("../../rpc/wsTransport", () => ({161vi.mock("../../rpc/wsTransport", () => ({
78 WsTransport: MockWsTransport,162 WsTransport: MockWsTransport,
...@@ -184,6 +268,17 @@ describe("retainThreadDetailSubscription", () => {...@@ -184,6 +268,17 @@ describe("retainThreadDetailSubscription", () => {
184 mockCreateEnvironmentConnection.mockImplementation((input) => {268 mockCreateEnvironmentConnection.mockImplementation((input) => {
185 const reconnect = vi.fn(async () => undefined);269 const reconnect = vi.fn(async () => undefined);
186 mockConnectionReconnects.push(reconnect);270 mockConnectionReconnects.push(reconnect);
271 queueMicrotask(() => {
272 input.onConfigSnapshot?.({
273 environment: {
274 environmentId: input.knownEnvironment.environmentId,
275 label: input.knownEnvironment.label,
276 platform: { os: "darwin", arch: "arm64" },
277 serverVersion: "0.0.0-test",
278 capabilities: { repositoryIdentity: true },
279 },
280 });
281 });
187 return {282 return {
188 kind: input.kind,283 kind: input.kind,
189 environmentId: input.knownEnvironment.environmentId,284 environmentId: input.knownEnvironment.environmentId,
...@@ -353,13 +448,13 @@ describe("retainThreadDetailSubscription", () => {...@@ -353,13 +448,13 @@ describe("retainThreadDetailSubscription", () => {
353 const stop = startEnvironmentConnectionService(new QueryClient());448 const stop = startEnvironmentConnectionService(new QueryClient());
354 savedEnvironmentRegistryListener?.();449 savedEnvironmentRegistryListener?.();
355 await vi.waitFor(() => {450 await vi.waitFor(() => {
356 expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(2);
357 expect(451 expect(
358 listEnvironmentConnections().some(452 listEnvironmentConnections().some(
359 (connection) => connection.environmentId === environmentId,453 (connection) => connection.environmentId === environmentId,
360 ),454 ),
361 ).toBe(true);455 ).toBe(true);
362 });456 });
457 const createConnectionCallsBeforeReconnect = mockCreateEnvironmentConnection.mock.calls.length;
363458
364 const release = retainThreadDetailSubscription(environmentId, threadId);459 const release = retainThreadDetailSubscription(environmentId, threadId);
365 expect(mockSubscribeThread).toHaveBeenCalledTimes(1);460 expect(mockSubscribeThread).toHaveBeenCalledTimes(1);
...@@ -374,7 +469,9 @@ describe("retainThreadDetailSubscription", () => {...@@ -374,7 +469,9 @@ describe("retainThreadDetailSubscription", () => {
374 await vi.advanceTimersByTimeAsync(200);469 await vi.advanceTimersByTimeAsync(200);
375 await reconnectPromise;470 await reconnectPromise;
376 await vi.waitFor(() => {471 await vi.waitFor(() => {
377 expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(3);472 expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(
473 createConnectionCallsBeforeReconnect + 1,
474 );
378 expect(mockSubscribeThread).toHaveBeenCalledTimes(2);475 expect(mockSubscribeThread).toHaveBeenCalledTimes(2);
379 });476 });
380477
...@@ -401,6 +498,33 @@ describe("retainThreadDetailSubscription", () => {...@@ -401,6 +498,33 @@ describe("retainThreadDetailSubscription", () => {
401498
402 const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } =499 const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } =
403 await import("./service");500 await import("./service");
501 mockCreateEnvironmentConnection.mockImplementation((input) => {
502 const reconnect = vi.fn(async () => undefined);
503 mockConnectionReconnects.push(reconnect);
504 queueMicrotask(() => {
505 input.onConfigSnapshot?.({
506 environment: {
507 environmentId: input.knownEnvironment.environmentId,
508 label: input.knownEnvironment.label,
509 platform: { os: "darwin", arch: "arm64" },
510 serverVersion: "0.0.0-test",
511 capabilities: { repositoryIdentity: true },
512 },
513 });
514 });
515 return {
516 kind: input.kind,
517 environmentId: input.knownEnvironment.environmentId,
518 knownEnvironment: input.knownEnvironment,
519 client: {
520 ...input.client,
521 isHeartbeatFresh: vi.fn(() => true),
522 },
523 ensureBootstrapped: vi.fn(async () => undefined),
524 reconnect,
525 dispose: vi.fn(async () => undefined),
526 };
527 });
404528
405 const stop = startEnvironmentConnectionService(new QueryClient());529 const stop = startEnvironmentConnectionService(new QueryClient());
406 expect(mockConnectionReconnects).toHaveLength(1);530 expect(mockConnectionReconnects).toHaveLength(1);
apps/web/src/environments/runtime/service.ts+119-88
...@@ -7,9 +7,18 @@ import {...@@ -7,9 +7,18 @@ import {
7 type OrchestrationShellSnapshot,7 type OrchestrationShellSnapshot,
8 type OrchestrationShellStreamEvent,8 type OrchestrationShellStreamEvent,
9 type ServerConfig,9 type ServerConfig,
10 type TerminalEvent,
11 ThreadId,10 ThreadId,
12} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import {
13 createWsRpcClient as createBaseWsRpcClient,
14 type WsRpcClient,
15 bootstrapRemoteBearerSession,
16 fetchRemoteEnvironmentDescriptor,
17 fetchRemoteSessionState,
18 isRemoteEnvironmentAuthHttpError,
19 resolveRemoteWebSocketConnectionUrl,
20} from "@t3tools/client-runtime";
21
13import { type QueryClient } from "@tanstack/react-query";22import { type QueryClient } from "@tanstack/react-query";
14import { Throttler } from "@tanstack/react-pacer";23import { Throttler } from "@tanstack/react-pacer";
15import {24import {
...@@ -26,19 +35,11 @@ import {...@@ -26,19 +35,11 @@ import {
26 useComposerDraftStore,35 useComposerDraftStore,
27} from "~/composerDraftStore";36} from "~/composerDraftStore";
28import { ensureLocalApi } from "~/localApi";37import { ensureLocalApi } from "~/localApi";
29import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup";38import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";
30import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";39import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
31import { projectQueryKeys } from "~/lib/projectReactQuery";
32import { providerQueryKeys } from "~/lib/providerReactQuery";
33import { getPrimaryKnownEnvironment } from "../primary";40import { getPrimaryKnownEnvironment } from "../primary";
34import {41import { remoteHttpRuntime } from "../../lib/runtime";
35 bootstrapRemoteBearerSession,42
36 fetchRemoteEnvironmentDescriptor,
37 fetchRemoteSessionState,
38 isRemoteEnvironmentAuthHttpError,
39 resolveRemoteWebSocketConnectionUrl,
40} from "../remote/api";
41import { resolveRemotePairingTarget } from "../remote/target";
42import {43import {
43 getSavedEnvironmentRecord,44 getSavedEnvironmentRecord,
44 hasSavedEnvironmentRegistryHydrated,45 hasSavedEnvironmentRegistryHydrated,
...@@ -53,7 +54,12 @@ import {...@@ -53,7 +54,12 @@ import {
53 waitForSavedEnvironmentRegistryHydration,54 waitForSavedEnvironmentRegistryHydration,
54 writeSavedEnvironmentBearerToken,55 writeSavedEnvironmentBearerToken,
55} from "./catalog";56} from "./catalog";
56import { createEnvironmentConnection, type EnvironmentConnection } from "./connection";57import {
58 createEnvironmentConnection,
59 createEnvironmentConnectionAttemptRegistry,
60 EnvironmentConnectionAttemptCancelledError,
61 type EnvironmentConnection,
62} from "./connection";
57import {63import {
58 useStore,64 useStore,
59 selectProjectsAcrossEnvironments,65 selectProjectsAcrossEnvironments,
...@@ -61,18 +67,19 @@ import {...@@ -61,18 +67,19 @@ import {
61 selectThreadByRef,67 selectThreadByRef,
62 selectThreadsAcrossEnvironments,68 selectThreadsAcrossEnvironments,
63} from "~/store";69} from "~/store";
64import { useTerminalStateStore } from "~/terminalStateStore";70import { useTerminalUiStateStore } from "~/terminalUiStateStore";
65import { useUiStateStore } from "~/uiStateStore";71import { useUiStateStore } from "~/uiStateStore";
66import type { WsProtocolCloseContext } from "../../rpc/protocol";
67import { getServerConfig } from "../../rpc/serverState";72import { getServerConfig } from "../../rpc/serverState";
68import { WsTransport } from "../../rpc/wsTransport";73import { WsTransport } from "~/rpc/wsTransport";
69import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
70import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew";74import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew";
71import {75import {
72 deriveLogicalProjectKeyFromSettings,76 deriveLogicalProjectKeyFromSettings,
73 derivePhysicalProjectKey,77 derivePhysicalProjectKey,
74} from "../../logicalProject";78} from "../../logicalProject";
75import { getClientSettings } from "~/hooks/useSettings";79import { getClientSettings } from "~/hooks/useSettings";
80import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState";
81import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState";
82import { resolveRemotePairingTarget } from "@t3tools/shared/remote";
7683
77type EnvironmentServiceState = {84type EnvironmentServiceState = {
78 readonly queryClient: QueryClient;85 readonly queryClient: QueryClient;
...@@ -92,29 +99,25 @@ type ThreadDetailSubscriptionEntry = {...@@ -92,29 +99,25 @@ type ThreadDetailSubscriptionEntry = {
92};99};
93100
94const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();101const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();
95class SavedEnvironmentConnectionCancelledError extends Error {
96 constructor(environmentId: EnvironmentId) {
97 super(`Saved environment ${environmentId} connection was cancelled.`);
98 this.name = "SavedEnvironmentConnectionCancelledError";
99 }
100}
101102
102function isSavedEnvironmentConnectionCancelledError(103function isSavedEnvironmentConnectionCancelledError(
103 error: unknown,104 error: unknown,
104): error is SavedEnvironmentConnectionCancelledError {105): error is EnvironmentConnectionAttemptCancelledError {
105 return error instanceof SavedEnvironmentConnectionCancelledError;106 return error instanceof EnvironmentConnectionAttemptCancelledError;
106}107}
107108
108interface PendingSavedEnvironmentConnection {109interface PendingSavedEnvironmentConnection {
109 cancelled: boolean;110 readonly isCurrent: () => boolean;
110 readonly promise: Promise<EnvironmentConnection>;111 readonly promise: Promise<EnvironmentConnection>;
111}112}
112113
114const savedEnvironmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry();
113const pendingSavedEnvironmentConnections = new Map<115const pendingSavedEnvironmentConnections = new Map<
114 EnvironmentId,116 EnvironmentId,
115 PendingSavedEnvironmentConnection117 PendingSavedEnvironmentConnection
116>();118>();
117const environmentConnectionListeners = new Set<() => void>();119const environmentConnectionListeners = new Set<() => void>();
120const providerInvalidationListeners = new Set<() => void>();
118const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>();121const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>();
119const lastAppliedProjectionVersionByEnvironment = new Map<122const lastAppliedProjectionVersionByEnvironment = new Map<
120 EnvironmentId,123 EnvironmentId,
...@@ -123,12 +126,20 @@ const lastAppliedProjectionVersionByEnvironment = new Map<...@@ -123,12 +126,20 @@ const lastAppliedProjectionVersionByEnvironment = new Map<
123 readonly updatedAt: string | null;126 readonly updatedAt: string | null;
124 }127 }
125>();128>();
129const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>();
126130
127let activeService: EnvironmentServiceState | null = null;131let activeService: EnvironmentServiceState | null = null;
128let needsProviderInvalidation = false;132let needsProviderInvalidation = false;
129let lastBrowserHiddenAt: number | null = null;133let lastBrowserHiddenAt: number | null = null;
130let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;134let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;
131135
136// TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY):
137// This file still owns web's legacy thread-detail subscription cache. Mobile
138// uses createThreadDetailManager from @t3tools/client-runtime for the same
139// retain/reconnect/evict lifecycle. When touching this logic, prefer migrating
140// web to the shared manager or extracting the missing adapter layer instead of
141// adding more behavior here.
142//
132// Thread detail subscription cache policy:143// Thread detail subscription cache policy:
133// - Active consumers keep a subscription retained via refCount.144// - Active consumers keep a subscription retained via refCount.
134// - Released subscriptions stay warm for a longer idle TTL to avoid churn145// - Released subscriptions stay warm for a longer idle TTL to avoid churn
...@@ -592,6 +603,12 @@ function emitEnvironmentConnectionRegistryChange() {...@@ -592,6 +603,12 @@ function emitEnvironmentConnectionRegistryChange() {
592 }603 }
593}604}
594605
606function emitProviderInvalidation() {
607 for (const listener of providerInvalidationListeners) {
608 listener();
609 }
610}
611
595function getRuntimeErrorFields(error: unknown) {612function getRuntimeErrorFields(error: unknown) {
596 return {613 return {
597 lastError: error instanceof Error ? error.message : String(error),614 lastError: error instanceof Error ? error.message : String(error),
...@@ -932,7 +949,7 @@ function reconcileSnapshotDerivedState() {...@@ -932,7 +949,7 @@ function reconcileSnapshotDerivedState() {
932 syncThreadUiFromStore();949 syncThreadUiFromStore();
933950
934 const threads = selectThreadsAcrossEnvironments(useStore.getState());951 const threads = selectThreadsAcrossEnvironments(useStore.getState());
935 const activeThreadKeys = collectActiveTerminalThreadIds({952 const activeThreadKeys = collectActiveTerminalUiThreadKeys({
936 snapshotThreads: threads.map((thread) => ({953 snapshotThreads: threads.map((thread) => ({
937 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),954 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
938 deletedAt: null,955 deletedAt: null,
...@@ -940,18 +957,7 @@ function reconcileSnapshotDerivedState() {...@@ -940,18 +957,7 @@ function reconcileSnapshotDerivedState() {
940 })),957 })),
941 draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(),958 draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(),
942 });959 });
943 useTerminalStateStore.getState().removeOrphanedTerminalStates(activeThreadKeys);960 useTerminalUiStateStore.getState().removeOrphanedTerminalUiStates(activeThreadKeys);
944}
945
946export function shouldApplyTerminalEvent(input: {
947 serverThreadArchivedAt: string | null | undefined;
948 hasDraftThread: boolean;
949}): boolean {
950 if (input.serverThreadArchivedAt !== undefined) {
951 return input.serverThreadArchivedAt === null;
952 }
953
954 return input.hasDraftThread;
955}961}
956962
957function applyRecoveredEventBatch(963function applyRecoveredEventBatch(
...@@ -1017,8 +1023,10 @@ function applyRecoveredEventBatch(...@@ -1017,8 +1023,10 @@ function applyRecoveredEventBatch(
1017 draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId));1023 draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId));
1018 }1024 }
1019 }1025 }
1020 for (const threadId of batchEffects.removeTerminalStateThreadIds) {1026 for (const threadId of batchEffects.removeTerminalUiStateThreadIds) {
1021 useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId));1027 useTerminalUiStateStore
1028 .getState()
1029 .removeTerminalUiState(scopeThreadRef(environmentId, threadId));
1022 }1030 }
10231031
1024 reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId);1032 reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId);
...@@ -1064,7 +1072,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En...@@ -1064,7 +1072,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En
1064 markPromotedDraftThreadByRef(threadRef);1072 markPromotedDraftThreadByRef(threadRef);
1065 }1073 }
1066 if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) {1074 if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) {
1067 useTerminalStateStore.getState().removeTerminalState(threadRef);1075 useTerminalUiStateStore.getState().removeTerminalUiState(threadRef);
1068 }1076 }
1069 reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id);1077 reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id);
1070 evictIdleThreadDetailSubscriptionsToCapacity();1078 evictIdleThreadDetailSubscriptionsToCapacity();
...@@ -1074,7 +1082,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En...@@ -1074,7 +1082,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En
1074 disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef));1082 disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef));
1075 useComposerDraftStore.getState().clearDraftThread(threadRef);1083 useComposerDraftStore.getState().clearDraftThread(threadRef);
1076 useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef));1084 useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef));
1077 useTerminalStateStore.getState().removeTerminalState(threadRef);1085 useTerminalUiStateStore.getState().removeTerminalUiState(threadRef);
1078 }1086 }
1079 syncThreadUiFromStore();1087 syncThreadUiFromStore();
1080 return;1088 return;
...@@ -1085,6 +1093,11 @@ function createEnvironmentConnectionHandlers() {...@@ -1085,6 +1093,11 @@ function createEnvironmentConnectionHandlers() {
1085 return {1093 return {
1086 applyShellEvent,1094 applyShellEvent,
1087 syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => {1095 syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => {
1096 // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY):
1097 // Shell snapshots already have createShellSnapshotManager in
1098 // @t3tools/client-runtime. Web currently projects snapshots straight into
1099 // its denormalized Zustand store; future shell changes should migrate or
1100 // bridge to the shared manager instead of growing this handler.
1088 if (1101 if (
1089 !shouldApplyProjectionSnapshot({1102 !shouldApplyProjectionSnapshot({
1090 current: readLastAppliedProjectionVersion(environmentId),1103 current: readLastAppliedProjectionVersion(environmentId),
...@@ -1103,24 +1116,15 @@ function createEnvironmentConnectionHandlers() {...@@ -1103,24 +1116,15 @@ function createEnvironmentConnectionHandlers() {
1103 reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId);1116 reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId);
1104 reconcileSnapshotDerivedState();1117 reconcileSnapshotDerivedState();
1105 },1118 },
1106 applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => {
1107 const threadRef = scopeThreadRef(environmentId, ThreadId.make(event.threadId));
1108 const serverThread = selectThreadByRef(useStore.getState(), threadRef);
1109 const hasDraftThread =
1110 useComposerDraftStore.getState().getDraftThreadByRef(threadRef) !== null;
1111 if (
1112 !shouldApplyTerminalEvent({
1113 serverThreadArchivedAt: serverThread?.archivedAt,
1114 hasDraftThread,
1115 })
1116 ) {
1117 return;
1118 }
1119 useTerminalStateStore.getState().applyTerminalEvent(threadRef, event);
1120 },
1121 };1119 };
1122}1120}
11231121
1122function createWsRpcClient(transport: WsTransport): WsRpcClient {
1123 return createBaseWsRpcClient(transport, {
1124 beforeReconnect: () => resetWsReconnectBackoff(),
1125 });
1126}
1127
1124function createPrimaryEnvironmentClient(1128function createPrimaryEnvironmentClient(
1125 knownEnvironment: ReturnType<typeof getPrimaryKnownEnvironment>,1129 knownEnvironment: ReturnType<typeof getPrimaryKnownEnvironment>,
1126) {1130) {
...@@ -1160,11 +1164,13 @@ function createSavedEnvironmentClient(...@@ -1160,11 +1164,13 @@ function createSavedEnvironmentClient(
1160 record.httpBaseUrl,1164 record.httpBaseUrl,
1161 bearerToken,1165 bearerToken,
1162 )1166 )
1163 : await resolveRemoteWebSocketConnectionUrl({1167 : await remoteHttpRuntime.runPromise(
1164 wsBaseUrl: record.wsBaseUrl,1168 resolveRemoteWebSocketConnectionUrl({
1165 httpBaseUrl: record.httpBaseUrl,1169 wsBaseUrl: record.wsBaseUrl,
1166 bearerToken,1170 httpBaseUrl: record.httpBaseUrl,
1167 });1171 bearerToken,
1172 }),
1173 );
1168 },1174 },
1169 {1175 {
1170 getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null,1176 getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null,
...@@ -1188,13 +1194,7 @@ function createSavedEnvironmentClient(...@@ -1188,13 +1194,7 @@ function createSavedEnvironmentClient(
1188 lastErrorAt: isoNow(),1194 lastErrorAt: isoNow(),
1189 });1195 });
1190 },1196 },
1191 onClose: (1197 onClose: (details: { readonly code: number; readonly reason: string }) => {
1192 details: { readonly code: number; readonly reason: string },
1193 context: WsProtocolCloseContext,
1194 ) => {
1195 if (context.intentional) {
1196 return;
1197 }
1198 setRuntimeDisconnected(1198 setRuntimeDisconnected(
1199 environmentId,1199 environmentId,
1200 appendVersionMismatchHint(1200 appendVersionMismatchHint(
...@@ -1226,10 +1226,12 @@ async function refreshSavedEnvironmentMetadata(...@@ -1226,10 +1226,12 @@ async function refreshSavedEnvironmentMetadata(
1226 configHint ? Promise.resolve(configHint) : client.server.getConfig(),1226 configHint ? Promise.resolve(configHint) : client.server.getConfig(),
1227 record.desktopSsh1227 record.desktopSsh
1228 ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken)1228 ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken)
1229 : fetchRemoteSessionState({1229 : remoteHttpRuntime.runPromise(
1230 httpBaseUrl: record.httpBaseUrl,1230 fetchRemoteSessionState({
1231 bearerToken,1231 httpBaseUrl: record.httpBaseUrl,
1232 }),1232 bearerToken,
1233 }),
1234 ),
1233 ]);1235 ]);
12341236
1235 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {1237 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
...@@ -1249,6 +1251,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne...@@ -1249,6 +1251,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne
1249 throw new Error(`Environment ${connection.environmentId} already has an active connection.`);1251 throw new Error(`Environment ${connection.environmentId} already has an active connection.`);
1250 }1252 }
1251 environmentConnections.set(connection.environmentId, connection);1253 environmentConnections.set(connection.environmentId, connection);
1254 terminalMetadataSubscriptions.get(connection.environmentId)?.();
1255 terminalMetadataSubscriptions.set(
1256 connection.environmentId,
1257 subscribeTerminalMetadata({
1258 environmentId: connection.environmentId,
1259 client: connection.client,
1260 }),
1261 );
1252 attachThreadDetailSubscriptionsForEnvironment(connection.environmentId);1262 attachThreadDetailSubscriptionsForEnvironment(connection.environmentId);
1253 emitEnvironmentConnectionRegistryChange();1263 emitEnvironmentConnectionRegistryChange();
1254 return connection;1264 return connection;
...@@ -1262,6 +1272,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>...@@ -1262,6 +1272,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>
12621272
1263 lastAppliedProjectionVersionByEnvironment.delete(environmentId);1273 lastAppliedProjectionVersionByEnvironment.delete(environmentId);
1264 environmentConnections.delete(environmentId);1274 environmentConnections.delete(environmentId);
1275 terminalMetadataSubscriptions.get(environmentId)?.();
1276 terminalMetadataSubscriptions.delete(environmentId);
1277 terminalSessionManager.invalidateEnvironment(environmentId);
1265 emitEnvironmentConnectionRegistryChange();1278 emitEnvironmentConnectionRegistryChange();
1266 detachThreadDetailSubscriptionsForEnvironment(environmentId);1279 detachThreadDetailSubscriptionsForEnvironment(environmentId);
1267 await connection.dispose();1280 await connection.dispose();
...@@ -1312,8 +1325,9 @@ async function ensureSavedEnvironmentConnection(...@@ -1312,8 +1325,9 @@ async function ensureSavedEnvironmentConnection(
1312 return pending.promise;1325 return pending.promise;
1313 }1326 }
13141327
1328 const attempt = savedEnvironmentConnectionAttempts.begin(record.environmentId);
1315 const pendingEntry: PendingSavedEnvironmentConnection = {1329 const pendingEntry: PendingSavedEnvironmentConnection = {
1316 cancelled: false,1330 isCurrent: attempt.isCurrent,
1317 promise: Promise.resolve().then(async () => {1331 promise: Promise.resolve().then(async () => {
1318 let activeRecord = record;1332 let activeRecord = record;
1319 let roleHint = options?.role ?? null;1333 let roleHint = options?.role ?? null;
...@@ -1425,16 +1439,16 @@ async function ensureSavedEnvironmentConnection(...@@ -1425,16 +1439,16 @@ async function ensureSavedEnvironmentConnection(
1425 });1439 });
1426 }1440 }
1427 if (1441 if (
1428 pendingEntry.cancelled ||1442 !pendingEntry.isCurrent() ||
1429 pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry1443 pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry
1430 ) {1444 ) {
1431 await connection.dispose().catch(() => undefined);1445 await connection.dispose().catch(() => undefined);
1432 throw new SavedEnvironmentConnectionCancelledError(activeRecord.environmentId);1446 throw new EnvironmentConnectionAttemptCancelledError(activeRecord.environmentId);
1433 }1447 }
1434 registerConnection(connection);1448 registerConnection(connection);
1435 return connection;1449 return connection;
1436 } catch (error) {1450 } catch (error) {
1437 if (error instanceof SavedEnvironmentConnectionCancelledError) {1451 if (error instanceof EnvironmentConnectionAttemptCancelledError) {
1438 throw error;1452 throw error;
1439 }1453 }
1440 setRuntimeError(activeRecord.environmentId, error);1454 setRuntimeError(activeRecord.environmentId, error);
...@@ -1451,6 +1465,7 @@ async function ensureSavedEnvironmentConnection(...@@ -1451,6 +1465,7 @@ async function ensureSavedEnvironmentConnection(
1451 return await pendingEntry.promise.finally(() => {1465 return await pendingEntry.promise.finally(() => {
1452 if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) {1466 if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) {
1453 pendingSavedEnvironmentConnections.delete(record.environmentId);1467 pendingSavedEnvironmentConnections.delete(record.environmentId);
1468 savedEnvironmentConnectionAttempts.cancel(record.environmentId);
1454 }1469 }
1455 });1470 });
1456}1471}
...@@ -1538,6 +1553,13 @@ export function subscribeEnvironmentConnections(listener: () => void): () => voi...@@ -1538,6 +1553,13 @@ export function subscribeEnvironmentConnections(listener: () => void): () => voi
1538 };1553 };
1539}1554}
15401555
1556export function subscribeProviderInvalidations(listener: () => void): () => void {
1557 providerInvalidationListeners.add(listener);
1558 return () => {
1559 providerInvalidationListeners.delete(listener);
1560 };
1561}
1562
1541export function listEnvironmentConnections(): ReadonlyArray<EnvironmentConnection> {1563export function listEnvironmentConnections(): ReadonlyArray<EnvironmentConnection> {
1542 return [...environmentConnections.values()];1564 return [...environmentConnections.values()];
1543}1565}
...@@ -1564,7 +1586,7 @@ export async function disconnectSavedEnvironment(environmentId: EnvironmentId):...@@ -1564,7 +1586,7 @@ export async function disconnectSavedEnvironment(environmentId: EnvironmentId):
1564 const record = getSavedEnvironmentRecord(environmentId);1586 const record = getSavedEnvironmentRecord(environmentId);
1565 const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId);1587 const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId);
1566 if (pendingConnection) {1588 if (pendingConnection) {
1567 pendingConnection.cancelled = true;1589 savedEnvironmentConnectionAttempts.cancel(environmentId);
1568 pendingSavedEnvironmentConnections.delete(environmentId);1590 pendingSavedEnvironmentConnections.delete(environmentId);
1569 }1591 }
1570 const connection = environmentConnections.get(environmentId);1592 const connection = environmentConnections.get(environmentId);
...@@ -1655,9 +1677,11 @@ export async function addSavedEnvironment(input: {...@@ -1655,9 +1677,11 @@ export async function addSavedEnvironment(input: {
1655 });1677 });
1656 const descriptor = input.desktopSsh1678 const descriptor = input.desktopSsh
1657 ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl)1679 ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl)
1658 : await fetchRemoteEnvironmentDescriptor({1680 : await remoteHttpRuntime.runPromise(
1659 httpBaseUrl: resolvedTarget.httpBaseUrl,1681 fetchRemoteEnvironmentDescriptor({
1660 });1682 httpBaseUrl: resolvedTarget.httpBaseUrl,
1683 }),
1684 );
1661 const environmentId = descriptor.environmentId;1685 const environmentId = descriptor.environmentId;
1662 const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]);1686 const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]);
1663 const existingRecord =1687 const existingRecord =
...@@ -1668,10 +1692,12 @@ export async function addSavedEnvironment(input: {...@@ -1668,10 +1692,12 @@ export async function addSavedEnvironment(input: {
16681692
1669 const bearerSession = input.desktopSsh1693 const bearerSession = input.desktopSsh
1670 ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential)1694 ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential)
1671 : await bootstrapRemoteBearerSession({1695 : await remoteHttpRuntime.runPromise(
1672 httpBaseUrl: resolvedTarget.httpBaseUrl,1696 bootstrapRemoteBearerSession({
1673 credential: resolvedTarget.credential,1697 httpBaseUrl: resolvedTarget.httpBaseUrl,
1674 });1698 credential: resolvedTarget.credential,
1699 }),
1700 );
16751701
1676 const record: SavedEnvironmentRecord = {1702 const record: SavedEnvironmentRecord = {
1677 environmentId,1703 environmentId,
...@@ -1763,8 +1789,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()...@@ -1763,8 +1789,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
1763 return;1789 return;
1764 }1790 }
1765 needsProviderInvalidation = false;1791 needsProviderInvalidation = false;
1766 void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });1792 emitProviderInvalidation();
1767 void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all });
1768 },1793 },
1769 {1794 {
1770 wait: 100,1795 wait: 100,
...@@ -1817,9 +1842,15 @@ export async function resetEnvironmentServiceForTests(): Promise<void> {...@@ -1817,9 +1842,15 @@ export async function resetEnvironmentServiceForTests(): Promise<void> {
1817 lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;1842 lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;
1818 lastAppliedProjectionVersionByEnvironment.clear();1843 lastAppliedProjectionVersionByEnvironment.clear();
1819 pendingSavedEnvironmentConnections.clear();1844 pendingSavedEnvironmentConnections.clear();
1845 savedEnvironmentConnectionAttempts.clear();
1820 for (const key of Array.from(threadDetailSubscriptions.keys())) {1846 for (const key of Array.from(threadDetailSubscriptions.keys())) {
1821 disposeThreadDetailSubscriptionByKey(key);1847 disposeThreadDetailSubscriptionByKey(key);
1822 }1848 }
1849 for (const unsubscribe of terminalMetadataSubscriptions.values()) {
1850 unsubscribe();
1851 }
1852 terminalMetadataSubscriptions.clear();
1853 terminalSessionManager.reset();
1823 await Promise.all(1854 await Promise.all(
1824 [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)),1855 [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)),
1825 );1856 );
apps/web/src/hooks/useThreadActions.ts+6-9
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";1import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import { useQueryClient } from "@tanstack/react-query";
4import { useRouter } from "@tanstack/react-router";3import { useRouter } from "@tanstack/react-router";
5import { useCallback, useRef } from "react";4import { useCallback, useRef } from "react";
65
...@@ -8,7 +7,7 @@ import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";...@@ -8,7 +7,7 @@ import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
8import { useComposerDraftStore } from "../composerDraftStore";7import { useComposerDraftStore } from "../composerDraftStore";
9import { useNewThreadHandler } from "./useHandleNewThread";8import { useNewThreadHandler } from "./useHandleNewThread";
10import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";9import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
11import { invalidateGitQueries } from "../lib/gitReactQuery";10import { invalidateSourceControlState } from "../lib/sourceControlActions";
12import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";11import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
13import { newCommandId } from "../lib/utils";12import { newCommandId } from "../lib/utils";
14import { readLocalApi } from "../localApi";13import { readLocalApi } from "../localApi";
...@@ -18,7 +17,7 @@ import {...@@ -18,7 +17,7 @@ import {
18 selectThreadsForEnvironment,17 selectThreadsForEnvironment,
19 useStore,18 useStore,
20} from "../store";19} from "../store";
21import { useTerminalStateStore } from "../terminalStateStore";20import { useTerminalUiStateStore } from "../terminalUiStateStore";
22import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";21import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
23import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";22import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
24import { stackedThreadToast, toastManager } from "../components/ui/toast";23import { stackedThreadToast, toastManager } from "../components/ui/toast";
...@@ -31,7 +30,7 @@ export function useThreadActions() {...@@ -31,7 +30,7 @@ export function useThreadActions() {
31 const clearProjectDraftThreadById = useComposerDraftStore(30 const clearProjectDraftThreadById = useComposerDraftStore(
32 (store) => store.clearProjectDraftThreadById,31 (store) => store.clearProjectDraftThreadById,
33 );32 );
34 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);33 const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState);
35 const router = useRouter();34 const router = useRouter();
36 const { handleNewThread } = useNewThreadHandler();35 const { handleNewThread } = useNewThreadHandler();
37 // Keep a ref so archiveThread can call handleNewThread without appearing in36 // Keep a ref so archiveThread can call handleNewThread without appearing in
...@@ -40,7 +39,6 @@ export function useThreadActions() {...@@ -40,7 +39,6 @@ export function useThreadActions() {
40 // sidebar row via archiveThread → attemptArchiveThread.39 // sidebar row via archiveThread → attemptArchiveThread.
41 const handleNewThreadRef = useRef(handleNewThread);40 const handleNewThreadRef = useRef(handleNewThread);
42 handleNewThreadRef.current = handleNewThread;41 handleNewThreadRef.current = handleNewThread;
43 const queryClient = useQueryClient();
4442
45 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {43 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {
46 const state = useStore.getState();44 const state = useStore.getState();
...@@ -195,7 +193,7 @@ export function useThreadActions() {...@@ -195,7 +193,7 @@ export function useThreadActions() {
195 scopeProjectRef(threadRef.environmentId, thread.projectId),193 scopeProjectRef(threadRef.environmentId, thread.projectId),
196 threadRef,194 threadRef,
197 );195 );
198 clearTerminalState(threadRef);196 clearTerminalUiState(threadRef);
199197
200 if (shouldNavigateToFallback) {198 if (shouldNavigateToFallback) {
201 if (fallbackThreadId) {199 if (fallbackThreadId) {
...@@ -229,7 +227,7 @@ export function useThreadActions() {...@@ -229,7 +227,7 @@ export function useThreadActions() {
229 path: orphanedWorktreePath,227 path: orphanedWorktreePath,
230 force: true,228 force: true,
231 });229 });
232 await invalidateGitQueries(queryClient, {230 await invalidateSourceControlState({
233 environmentId: threadRef.environmentId,231 environmentId: threadRef.environmentId,
234 });232 });
235 } catch (error) {233 } catch (error) {
...@@ -252,10 +250,9 @@ export function useThreadActions() {...@@ -252,10 +250,9 @@ export function useThreadActions() {
252 [250 [
253 clearComposerDraftForThread,251 clearComposerDraftForThread,
254 clearProjectDraftThreadById,252 clearProjectDraftThreadById,
255 clearTerminalState,253 clearTerminalUiState,
256 getCurrentRouteThreadRef,254 getCurrentRouteThreadRef,
257 router,255 router,
258 queryClient,
259 resolveThreadTarget,256 resolveThreadTarget,
260 sidebarThreadSortOrder,257 sidebarThreadSortOrder,
261 ],258 ],
apps/web/src/lib/archivedThreadsState.ts+16-79
...@@ -1,83 +1,23 @@...@@ -1,83 +1,23 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";2import {
3import { AsyncResult, Atom } from "effect/unstable/reactivity";3 type ArchivedSnapshotEntry,
4 createArchivedThreadsManager,
5 makeArchivedThreadsEnvironmentKey,
6 readArchivedThreadsSnapshotState,
7} from "@t3tools/client-runtime";
8import type { EnvironmentId } from "@t3tools/contracts";
4import { useCallback, useMemo } from "react";9import { useCallback, useMemo } from "react";
5import * as Cause from "effect/Cause";10
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import { readEnvironmentApi } from "../environmentApi";11import { readEnvironmentApi } from "../environmentApi";
9import { appAtomRegistry } from "../rpc/atomRegistry";12import { appAtomRegistry } from "../rpc/atomRegistry";
1013
11const ARCHIVED_THREADS_STALE_TIME_MS = 5_000;14const archivedThreadsManager = createArchivedThreadsManager({
12const ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000;15 getRegistry: () => appAtomRegistry,
13const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";16 getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null,
14
15export type ArchivedSnapshotEntry = {
16 readonly environmentId: EnvironmentId;
17 readonly snapshot: OrchestrationShellSnapshot;
18};
19
20const knownArchivedThreadEnvironmentKeys = new Set<string>();
21
22function makeArchivedThreadsEnvironmentKey(environmentIds: ReadonlyArray<EnvironmentId>): string {
23 return environmentIds.toSorted().join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR);
24}
25
26function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<EnvironmentId> {
27 if (key.length === 0) {
28 return [];
29 }
30 return key
31 .split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR)
32 .map((environmentId) => EnvironmentId.make(environmentId));
33}
34
35const archivedThreadSnapshotsAtom = Atom.family((environmentKey: string) => {
36 knownArchivedThreadEnvironmentKeys.add(environmentKey);
37 return Atom.make(
38 Effect.promise(async (): Promise<ReadonlyArray<ArchivedSnapshotEntry>> => {
39 const environmentIds = parseArchivedThreadsEnvironmentKey(environmentKey);
40 const snapshots = await Promise.all(
41 environmentIds.map(async (environmentId) => {
42 const api = readEnvironmentApi(environmentId);
43 if (!api) {
44 return null;
45 }
46 return {
47 environmentId,
48 snapshot: await api.orchestration.getArchivedShellSnapshot(),
49 };
50 }),
51 );
52 return snapshots.filter((snapshot) => snapshot !== null);
53 }),
54 ).pipe(
55 Atom.swr({
56 staleTime: ARCHIVED_THREADS_STALE_TIME_MS,
57 revalidateOnMount: true,
58 }),
59 Atom.setIdleTTL(ARCHIVED_THREADS_IDLE_TTL_MS),
60 Atom.withLabel(`archived-thread-snapshots:${environmentKey}`),
61 );
62});17});
6318
64function readArchivedThreadsError(
65 result: AsyncResult.AsyncResult<ReadonlyArray<ArchivedSnapshotEntry>, unknown>,
66): string | null {
67 if (result._tag !== "Failure") {
68 return null;
69 }
70
71 const error = Cause.squash(result.cause);
72 return error instanceof Error ? error.message : "Failed to load archived threads.";
73}
74
75export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {19export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {
76 for (const key of knownArchivedThreadEnvironmentKeys) {20 archivedThreadsManager.refreshForEnvironment(environmentId);
77 if (parseArchivedThreadsEnvironmentKey(key).includes(environmentId)) {
78 appAtomRegistry.refresh(archivedThreadSnapshotsAtom(key));
79 }
80 }
81}21}
8222
83export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): {23export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): {
...@@ -90,17 +30,14 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<Environ...@@ -90,17 +30,14 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<Environ
90 () => makeArchivedThreadsEnvironmentKey(environmentIds),30 () => makeArchivedThreadsEnvironmentKey(environmentIds),
91 [environmentIds],31 [environmentIds],
92 );32 );
93 const atom = archivedThreadSnapshotsAtom(environmentKey);33 const atom = archivedThreadsManager.getAtom(environmentKey);
94 const result = useAtomValue(atom);34 const result = useAtomValue(atom);
95 const snapshots = Option.getOrElse(AsyncResult.value(result), () => []);
96 const refresh = useCallback(() => {35 const refresh = useCallback(() => {
97 appAtomRegistry.refresh(atom);36 archivedThreadsManager.refresh(environmentIds);
98 }, [atom]);37 }, [environmentIds]);
9938
100 return {39 return {
101 snapshots,40 ...readArchivedThreadsSnapshotState(result),
102 error: readArchivedThreadsError(result),
103 isLoading: result.waiting,
104 refresh,41 refresh,
105 };42 };
106}43}
apps/web/src/lib/checkpointDiffState.ts created+63
...@@ -0,0 +1,63 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type CheckpointDiffState,
4 type CheckpointDiffTarget,
5 checkpointDiffStateAtom,
6 createCheckpointDiffManager,
7 EMPTY_CHECKPOINT_DIFF_ATOM,
8 EMPTY_CHECKPOINT_DIFF_STATE,
9 getCheckpointDiffTargetKey,
10} from "@t3tools/client-runtime";
11import { useEffect, useMemo } from "react";
12
13import { readEnvironmentApi } from "../environmentApi";
14import { subscribeProviderInvalidations } from "../environments/runtime";
15import { appAtomRegistry } from "../rpc/atomRegistry";
16
17const checkpointDiffManager = createCheckpointDiffManager({
18 getRegistry: () => appAtomRegistry,
19 getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null,
20});
21
22export function invalidateCheckpointDiffs(): void {
23 checkpointDiffManager.invalidate();
24}
25
26subscribeProviderInvalidations(invalidateCheckpointDiffs);
27
28export function useCheckpointDiff(
29 target: CheckpointDiffTarget,
30 options?: { readonly enabled?: boolean },
31): CheckpointDiffState {
32 const stableTarget = useMemo(
33 () => ({
34 environmentId: target.environmentId,
35 threadId: target.threadId,
36 fromTurnCount: target.fromTurnCount,
37 toTurnCount: target.toTurnCount,
38 ignoreWhitespace: target.ignoreWhitespace,
39 cacheScope: target.cacheScope ?? null,
40 }),
41 [
42 target.cacheScope,
43 target.environmentId,
44 target.fromTurnCount,
45 target.ignoreWhitespace,
46 target.threadId,
47 target.toTurnCount,
48 ],
49 );
50 const targetKey = getCheckpointDiffTargetKey(stableTarget);
51
52 useEffect(() => {
53 if (targetKey === null || options?.enabled === false) {
54 return;
55 }
56 void checkpointDiffManager.load(stableTarget);
57 }, [options?.enabled, stableTarget, targetKey]);
58
59 const state = useAtomValue(
60 targetKey !== null ? checkpointDiffStateAtom(targetKey) : EMPTY_CHECKPOINT_DIFF_ATOM,
61 );
62 return targetKey === null || options?.enabled === false ? EMPTY_CHECKPOINT_DIFF_STATE : state;
63}
apps/web/src/lib/composerPathSearchState.ts created+57
...@@ -0,0 +1,57 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type ComposerPathSearchState,
4 type ComposerPathSearchTarget,
5 EMPTY_COMPOSER_PATH_SEARCH_ATOM,
6 EMPTY_COMPOSER_PATH_SEARCH_STATE,
7 composerPathSearchStateAtom,
8 createComposerPathSearchManager,
9 getComposerPathSearchTargetKey,
10 normalizeComposerPathSearchQuery,
11} from "@t3tools/client-runtime";
12import { useEffect, useMemo } from "react";
13
14import {
15 readEnvironmentConnection,
16 subscribeEnvironmentConnections,
17 subscribeProviderInvalidations,
18} from "../environments/runtime";
19import { appAtomRegistry } from "../rpc/atomRegistry";
20
21const COMPOSER_PATH_SEARCH_LIMIT = 80;
22const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;
23const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000;
24
25const composerPathSearchManager = createComposerPathSearchManager({
26 getRegistry: () => appAtomRegistry,
27 getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.projects ?? null,
28 subscribeClientChanges: subscribeEnvironmentConnections,
29 limit: COMPOSER_PATH_SEARCH_LIMIT,
30 debounceMs: COMPOSER_PATH_SEARCH_DEBOUNCE_MS,
31 staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS,
32});
33
34export function invalidateComposerPathSearches(): void {
35 composerPathSearchManager.invalidate();
36}
37
38subscribeProviderInvalidations(invalidateComposerPathSearches);
39
40export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState {
41 const stableTarget = useMemo(
42 () => ({
43 environmentId: target.environmentId,
44 cwd: target.cwd,
45 query: normalizeComposerPathSearchQuery(target.query),
46 }),
47 [target.cwd, target.environmentId, target.query],
48 );
49 const targetKey = getComposerPathSearchTargetKey(stableTarget);
50
51 useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]);
52
53 const state = useAtomValue(
54 targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM,
55 );
56 return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state;
57}
apps/web/src/lib/diffRendering.ts+73
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1import { parsePatchFiles } from "@pierre/diffs";
2import type { FileDiffMetadata } from "@pierre/diffs/react";
3
1export const DIFF_THEME_NAMES = {4export const DIFF_THEME_NAMES = {
2 light: "pierre-light",5 light: "pierre-light",
3 dark: "pierre-dark",6 dark: "pierre-dark",
...@@ -37,3 +40,73 @@ export function buildPatchCacheKey(patch: string, scope = "diff-panel"): string...@@ -37,3 +40,73 @@ export function buildPatchCacheKey(patch: string, scope = "diff-panel"): string
37 ).toString(36);40 ).toString(36);
38 return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;41 return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;
39}42}
43
44export type RenderablePatch =
45 | {
46 kind: "files";
47 files: FileDiffMetadata[];
48 }
49 | {
50 kind: "raw";
51 text: string;
52 reason: string;
53 };
54
55export function getRenderablePatch(
56 patch: string | undefined,
57 cacheScope = "diff-panel",
58): RenderablePatch | null {
59 if (!patch) return null;
60 const normalizedPatch = patch.trim();
61 if (normalizedPatch.length === 0) return null;
62
63 try {
64 const parsedPatches = parsePatchFiles(
65 normalizedPatch,
66 buildPatchCacheKey(normalizedPatch, cacheScope),
67 );
68 const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
69 if (files.length > 0) {
70 return { kind: "files", files };
71 }
72
73 return {
74 kind: "raw",
75 text: normalizedPatch,
76 reason: "Unsupported diff format. Showing raw patch.",
77 };
78 } catch {
79 return {
80 kind: "raw",
81 text: normalizedPatch,
82 reason: "Failed to parse patch. Showing raw patch.",
83 };
84 }
85}
86
87export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
88 const raw = fileDiff.name ?? fileDiff.prevName ?? "";
89 if (raw.startsWith("a/") || raw.startsWith("b/")) {
90 return raw.slice(2);
91 }
92 return raw;
93}
94
95export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
96 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
97}
98
99export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string {
100 switch (fileDiff.type) {
101 case "new":
102 return "text-[var(--diffs-addition-base)]";
103 case "deleted":
104 return "text-[var(--diffs-deletion-base)]";
105 case "change":
106 case "rename-pure":
107 case "rename-changed":
108 return "text-[var(--diffs-modified-base)]";
109 default:
110 return "text-muted-foreground/80";
111 }
112}
apps/web/src/lib/gitReactQuery.test.ts deleted-135
...@@ -1,135 +0,0 @@
1import { QueryClient } from "@tanstack/react-query";
2import { describe, expect, it, vi } from "vitest";
3
4vi.mock("../environmentApi", () => ({
5 ensureEnvironmentApi: vi.fn(),
6}));
7
8vi.mock("../wsRpcClient", () => ({
9 getWsRpcClient: vi.fn(),
10 getWsRpcClientForEnvironment: vi.fn(),
11}));
12
13import type { InfiniteData } from "@tanstack/react-query";
14import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
15
16import {
17 gitBranchSearchInfiniteQueryOptions,
18 gitMutationKeys,
19 gitPreparePullRequestThreadMutationOptions,
20 gitPullMutationOptions,
21 gitRunStackedActionMutationOptions,
22 invalidateGitQueries,
23} from "./gitReactQuery";
24
25const BRANCH_QUERY_RESULT: VcsListRefsResult = {
26 refs: [],
27 isRepo: true,
28 hasPrimaryRemote: true,
29 nextCursor: null,
30 totalCount: 0,
31};
32
33const BRANCH_SEARCH_RESULT: InfiniteData<VcsListRefsResult, number> = {
34 pages: [BRANCH_QUERY_RESULT],
35 pageParams: [0],
36};
37const ENVIRONMENT_A = EnvironmentId.make("environment-a");
38const ENVIRONMENT_B = EnvironmentId.make("environment-b");
39
40describe("gitMutationKeys", () => {
41 it("scopes stacked action keys by cwd", () => {
42 expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual(
43 gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"),
44 );
45 });
46
47 it("scopes pull keys by cwd", () => {
48 expect(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")).not.toEqual(
49 gitMutationKeys.pull(ENVIRONMENT_A, "/repo/b"),
50 );
51 });
52
53 it("scopes pull request thread preparation keys by cwd", () => {
54 expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual(
55 gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"),
56 );
57 });
58});
59
60describe("git mutation options", () => {
61 const queryClient = new QueryClient();
62
63 it("attaches cwd-scoped mutation key for runStackedAction", () => {
64 const options = gitRunStackedActionMutationOptions({
65 environmentId: ENVIRONMENT_A,
66 cwd: "/repo/a",
67 queryClient,
68 });
69 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a"));
70 });
71
72 it("attaches cwd-scoped mutation key for pull", () => {
73 const options = gitPullMutationOptions({
74 environmentId: ENVIRONMENT_A,
75 cwd: "/repo/a",
76 queryClient,
77 });
78 expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a"));
79 });
80
81 it("attaches cwd-scoped mutation key for preparePullRequestThread", () => {
82 const options = gitPreparePullRequestThreadMutationOptions({
83 environmentId: ENVIRONMENT_A,
84 cwd: "/repo/a",
85 queryClient,
86 });
87 expect(options.mutationKey).toEqual(
88 gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"),
89 );
90 });
91});
92
93describe("invalidateGitQueries", () => {
94 it("can invalidate a single cwd without blasting other git query scopes", async () => {
95 const queryClient = new QueryClient();
96
97 queryClient.setQueryData(
98 gitBranchSearchInfiniteQueryOptions({
99 environmentId: ENVIRONMENT_A,
100 cwd: "/repo/a",
101 query: "feature",
102 }).queryKey,
103 BRANCH_SEARCH_RESULT,
104 );
105 queryClient.setQueryData(
106 gitBranchSearchInfiniteQueryOptions({
107 environmentId: ENVIRONMENT_B,
108 cwd: "/repo/b",
109 query: "feature",
110 }).queryKey,
111 BRANCH_SEARCH_RESULT,
112 );
113
114 await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" });
115
116 expect(
117 queryClient.getQueryState(
118 gitBranchSearchInfiniteQueryOptions({
119 environmentId: ENVIRONMENT_A,
120 cwd: "/repo/a",
121 query: "feature",
122 }).queryKey,
123 )?.isInvalidated,
124 ).toBe(true);
125 expect(
126 queryClient.getQueryState(
127 gitBranchSearchInfiniteQueryOptions({
128 environmentId: ENVIRONMENT_B,
129 cwd: "/repo/b",
130 query: "feature",
131 }).queryKey,
132 )?.isInvalidated,
133 ).toBe(false);
134 });
135});
apps/web/src/lib/gitReactQuery.ts deleted-327
...@@ -1,327 +0,0 @@
1import {
2 type EnvironmentId,
3 type GitActionProgressEvent,
4 type GitStackedAction,
5 type SourceControlPublishRepositoryInput,
6 type ThreadId,
7} from "@t3tools/contracts";
8import {
9 infiniteQueryOptions,
10 mutationOptions,
11 queryOptions,
12 type QueryClient,
13} from "@tanstack/react-query";
14import { ensureEnvironmentApi } from "../environmentApi";
15import { requireEnvironmentConnection } from "../environments/runtime";
16
17const GIT_BRANCHES_STALE_TIME_MS = 15_000;
18const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
19const GIT_BRANCHES_PAGE_SIZE = 100;
20
21export const gitQueryKeys = {
22 all: ["git"] as const,
23 refs: (environmentId: EnvironmentId | null, cwd: string | null) =>
24 ["git", "refs", environmentId ?? null, cwd] as const,
25 branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) =>
26 ["git", "refs", environmentId ?? null, cwd, "search", query] as const,
27};
28
29export const gitMutationKeys = {
30 init: (environmentId: EnvironmentId | null, cwd: string | null) =>
31 ["git", "mutation", "init", environmentId ?? null, cwd] as const,
32 switchRef: (environmentId: EnvironmentId | null, cwd: string | null) =>
33 ["git", "mutation", "switchRef", environmentId ?? null, cwd] as const,
34 runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) =>
35 ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const,
36 pull: (environmentId: EnvironmentId | null, cwd: string | null) =>
37 ["git", "mutation", "pull", environmentId ?? null, cwd] as const,
38 preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) =>
39 ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const,
40 publishRepository: (environmentId: EnvironmentId | null, cwd: string | null) =>
41 ["git", "mutation", "publish-repository", environmentId ?? null, cwd] as const,
42};
43
44export function invalidateGitQueries(
45 queryClient: QueryClient,
46 input?: { environmentId?: EnvironmentId | null; cwd?: string | null },
47) {
48 const environmentId = input?.environmentId ?? null;
49 const cwd = input?.cwd ?? null;
50 if (cwd !== null) {
51 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) });
52 }
53
54 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
55}
56
57function invalidateGitBranchQueries(
58 queryClient: QueryClient,
59 environmentId: EnvironmentId | null,
60 cwd: string | null,
61) {
62 if (cwd === null) {
63 return Promise.resolve();
64 }
65
66 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) });
67}
68
69/**
70 * @deprecated Use a VCS-named query helper once the UI naming migration lands.
71 */
72export function gitBranchSearchInfiniteQueryOptions(input: {
73 environmentId: EnvironmentId | null;
74 cwd: string | null;
75 query: string;
76 enabled?: boolean;
77}) {
78 const normalizedQuery = input.query.trim();
79
80 return infiniteQueryOptions({
81 queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery),
82 initialPageParam: 0,
83 queryFn: async ({ pageParam }) => {
84 if (!input.cwd) throw new Error("Git refs are unavailable.");
85 if (!input.environmentId) throw new Error("Git refs are unavailable.");
86 const api = ensureEnvironmentApi(input.environmentId);
87 return api.vcs.listRefs({
88 cwd: input.cwd,
89 ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),
90 cursor: pageParam,
91 limit: GIT_BRANCHES_PAGE_SIZE,
92 });
93 },
94 getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
95 enabled: input.cwd !== null && (input.enabled ?? true),
96 staleTime: GIT_BRANCHES_STALE_TIME_MS,
97 refetchOnWindowFocus: true,
98 refetchOnReconnect: true,
99 refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS,
100 });
101}
102
103export function gitResolvePullRequestQueryOptions(input: {
104 environmentId: EnvironmentId | null;
105 cwd: string | null;
106 reference: string | null;
107}) {
108 return queryOptions({
109 queryKey: [
110 "git",
111 "pull-request",
112 input.environmentId ?? null,
113 input.cwd,
114 input.reference,
115 ] as const,
116 queryFn: async () => {
117 if (!input.cwd || !input.reference || !input.environmentId) {
118 throw new Error("Pull request lookup is unavailable.");
119 }
120 const api = ensureEnvironmentApi(input.environmentId);
121 return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference });
122 },
123 enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null,
124 staleTime: 30_000,
125 refetchOnWindowFocus: false,
126 refetchOnReconnect: false,
127 });
128}
129
130/**
131 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
132 */
133export function gitInitMutationOptions(input: {
134 environmentId: EnvironmentId | null;
135 cwd: string | null;
136 queryClient: QueryClient;
137}) {
138 return mutationOptions({
139 mutationKey: gitMutationKeys.init(input.environmentId, input.cwd),
140 mutationFn: async () => {
141 if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable.");
142 const api = ensureEnvironmentApi(input.environmentId);
143 return api.vcs.init({ cwd: input.cwd });
144 },
145 onSettled: async () => {
146 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
147 },
148 });
149}
150
151/**
152 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
153 */
154export function gitCheckoutMutationOptions(input: {
155 environmentId: EnvironmentId | null;
156 cwd: string | null;
157 queryClient: QueryClient;
158}) {
159 return mutationOptions({
160 mutationKey: gitMutationKeys.switchRef(input.environmentId, input.cwd),
161 mutationFn: async (refName: string) => {
162 if (!input.cwd || !input.environmentId) throw new Error("Git switchRef is unavailable.");
163 const api = ensureEnvironmentApi(input.environmentId);
164 return api.vcs.switchRef({ cwd: input.cwd, refName });
165 },
166 onSettled: async () => {
167 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
168 },
169 });
170}
171
172export function gitRunStackedActionMutationOptions(input: {
173 environmentId: EnvironmentId | null;
174 cwd: string | null;
175 queryClient: QueryClient;
176}) {
177 return mutationOptions({
178 mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd),
179 mutationFn: async ({
180 actionId,
181 action,
182 commitMessage,
183 featureBranch,
184 filePaths,
185 onProgress,
186 }: {
187 actionId: string;
188 action: GitStackedAction;
189 commitMessage?: string;
190 featureBranch?: boolean;
191 filePaths?: string[];
192 onProgress?: (event: GitActionProgressEvent) => void;
193 }) => {
194 if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable.");
195 return requireEnvironmentConnection(input.environmentId).client.git.runStackedAction(
196 {
197 action,
198 actionId,
199 cwd: input.cwd,
200 ...(commitMessage ? { commitMessage } : {}),
201 ...(featureBranch ? { featureBranch: true } : {}),
202 ...(filePaths && filePaths.length > 0 ? { filePaths } : {}),
203 },
204 ...(onProgress ? [{ onProgress }] : []),
205 );
206 },
207 onSuccess: async () => {
208 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
209 },
210 });
211}
212
213/**
214 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
215 */
216export function gitPullMutationOptions(input: {
217 environmentId: EnvironmentId | null;
218 cwd: string | null;
219 queryClient: QueryClient;
220}) {
221 return mutationOptions({
222 mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd),
223 mutationFn: async () => {
224 if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable.");
225 const api = ensureEnvironmentApi(input.environmentId);
226 return api.vcs.pull({ cwd: input.cwd });
227 },
228 onSuccess: async () => {
229 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
230 },
231 });
232}
233
234export function sourceControlPublishRepositoryMutationOptions(input: {
235 environmentId: EnvironmentId | null;
236 cwd: string | null;
237 queryClient: QueryClient;
238}) {
239 return mutationOptions({
240 mutationKey: gitMutationKeys.publishRepository(input.environmentId, input.cwd),
241 mutationFn: async (args: Omit<SourceControlPublishRepositoryInput, "cwd">) => {
242 if (!input.cwd || !input.environmentId) {
243 throw new Error("Repository publishing is unavailable.");
244 }
245 const api = ensureEnvironmentApi(input.environmentId);
246 return api.sourceControl.publishRepository({ cwd: input.cwd, ...args });
247 },
248 onSuccess: async () => {
249 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
250 },
251 });
252}
253
254/**
255 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
256 */
257export function gitCreateWorktreeMutationOptions(input: {
258 environmentId: EnvironmentId | null;
259 queryClient: QueryClient;
260}) {
261 return mutationOptions({
262 mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const,
263 mutationFn: (
264 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["createWorktree"]>[0],
265 ) => {
266 if (!input.environmentId) {
267 throw new Error("Worktree creation is unavailable.");
268 }
269 return ensureEnvironmentApi(input.environmentId).vcs.createWorktree(args);
270 },
271 onSuccess: async () => {
272 await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
273 },
274 });
275}
276
277/**
278 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
279 */
280export function gitRemoveWorktreeMutationOptions(input: {
281 environmentId: EnvironmentId | null;
282 queryClient: QueryClient;
283}) {
284 return mutationOptions({
285 mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const,
286 mutationFn: (
287 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["removeWorktree"]>[0],
288 ) => {
289 if (!input.environmentId) {
290 throw new Error("Worktree removal is unavailable.");
291 }
292 return ensureEnvironmentApi(input.environmentId).vcs.removeWorktree(args);
293 },
294 onSuccess: async () => {
295 await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
296 },
297 });
298}
299
300export function gitPreparePullRequestThreadMutationOptions(input: {
301 environmentId: EnvironmentId | null;
302 cwd: string | null;
303 queryClient: QueryClient;
304}) {
305 return mutationOptions({
306 mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd),
307 mutationFn: async (args: {
308 reference: string;
309 mode: "local" | "worktree";
310 threadId?: ThreadId;
311 }) => {
312 if (!input.cwd || !input.environmentId) {
313 throw new Error("Pull request thread preparation is unavailable.");
314 }
315 const api = ensureEnvironmentApi(input.environmentId);
316 return api.git.preparePullRequestThread({
317 cwd: input.cwd,
318 reference: args.reference,
319 mode: args.mode,
320 ...(args.threadId ? { threadId: args.threadId } : {}),
321 });
322 },
323 onSuccess: async () => {
324 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
325 },
326 });
327}
apps/web/src/lib/gitStatusState.test.ts deleted-327
...@@ -1,327 +0,0 @@
1import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vitest";
3
4import type { WsRpcClient } from "../rpc/wsRpcClient";
5import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry";
6import {
7 getGitStatusSnapshot,
8 resetGitStatusStateForTests,
9 refreshGitStatus,
10 watchGitStatus,
11} from "./gitStatusState";
12
13const serviceHarness = vi.hoisted(() => ({
14 connections: new Map<string, any>(),
15 listeners: new Set<() => void>(),
16}));
17
18vi.mock("../environments/runtime/service", () => ({
19 readEnvironmentConnection: (environmentId: string) =>
20 serviceHarness.connections.get(environmentId) ?? null,
21 subscribeEnvironmentConnections: (listener: () => void) => {
22 serviceHarness.listeners.add(listener);
23 return () => {
24 serviceHarness.listeners.delete(listener);
25 };
26 },
27}));
28
29function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
30 listeners.add(listener);
31 return () => {
32 listeners.delete(listener);
33 };
34}
35
36const gitStatusListeners = new Set<(event: VcsStatusResult) => void>();
37const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
38const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
39const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const;
40const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
41
42const BASE_STATUS: VcsStatusResult = {
43 isRepo: true,
44 hasPrimaryRemote: true,
45 isDefaultRef: false,
46 refName: "feature/push-status",
47 hasWorkingTreeChanges: false,
48 workingTree: { files: [], insertions: 0, deletions: 0 },
49 hasUpstream: true,
50 aheadCount: 0,
51 behindCount: 0,
52 pr: null,
53};
54
55const gitClient = {
56 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
57 ...BASE_STATUS,
58 refName: `${input.cwd}-refreshed`,
59 })),
60 onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
61 registerListener(gitStatusListeners, listener),
62 ),
63};
64
65function emitGitStatus(event: VcsStatusResult) {
66 for (const listener of gitStatusListeners) {
67 listener(event);
68 }
69}
70
71function createRegisteredGitStatusClient(environmentId: EnvironmentId) {
72 const listeners = new Set<(event: VcsStatusResult) => void>();
73 const client = {
74 dispose: vi.fn(async () => undefined),
75 reconnect: vi.fn(async () => undefined),
76 terminal: {
77 open: vi.fn(async () => undefined),
78 write: vi.fn(async () => undefined),
79 resize: vi.fn(async () => undefined),
80 clear: vi.fn(async () => undefined),
81 restart: vi.fn(async () => undefined),
82 close: vi.fn(async () => undefined),
83 onEvent: vi.fn(() => () => undefined),
84 },
85 projects: {
86 searchEntries: vi.fn(async () => []),
87 writeFile: vi.fn(async () => undefined),
88 },
89 shell: {
90 openInEditor: vi.fn(async () => undefined),
91 },
92 vcs: {
93 pull: vi.fn(async () => undefined),
94 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
95 ...BASE_STATUS,
96 refName: `${input.cwd}-refreshed`,
97 })),
98 onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
99 registerListener(listeners, listener),
100 ),
101 listRefs: vi.fn(async () => []),
102 createWorktree: vi.fn(async () => undefined),
103 removeWorktree: vi.fn(async () => undefined),
104 createRef: vi.fn(async () => undefined),
105 switchRef: vi.fn(async () => undefined),
106 init: vi.fn(async () => undefined),
107 },
108 git: {
109 runStackedAction: vi.fn(async () => ({}) as any),
110 resolvePullRequest: vi.fn(async () => undefined),
111 preparePullRequestThread: vi.fn(async () => undefined),
112 },
113 server: {
114 getConfig: vi.fn(async () => ({
115 environment: {
116 environmentId,
117 },
118 })),
119 refreshProviders: vi.fn(async () => undefined),
120 upsertKeybinding: vi.fn(async () => undefined),
121 getSettings: vi.fn(async () => undefined),
122 updateSettings: vi.fn(async () => undefined),
123 subscribeConfig: vi.fn(() => () => undefined),
124 subscribeLifecycle: vi.fn(() => () => undefined),
125 subscribeAuthAccess: vi.fn(() => () => undefined),
126 },
127 orchestration: {
128 dispatchCommand: vi.fn(async () => undefined),
129 getTurnDiff: vi.fn(async () => undefined),
130 getFullThreadDiff: vi.fn(async () => undefined),
131 subscribeShell: vi.fn(() => () => undefined),
132 subscribeThread: vi.fn(() => () => undefined),
133 },
134 } as unknown as WsRpcClient;
135
136 serviceHarness.connections.set(environmentId, {
137 kind: "saved" as const,
138 knownEnvironment: {
139 id: environmentId,
140 label: `Environment ${environmentId}`,
141 source: "manual" as const,
142 environmentId,
143 target: {
144 httpBaseUrl: "http://example.test",
145 wsBaseUrl: "ws://example.test",
146 },
147 },
148 client,
149 environmentId,
150 ensureBootstrapped: async () => undefined,
151 reconnect: async () => undefined,
152 dispose: async () => undefined,
153 });
154 for (const listener of serviceHarness.listeners) {
155 listener();
156 }
157
158 return {
159 client,
160 emit: (event: VcsStatusResult) => {
161 for (const listener of listeners) {
162 listener(event);
163 }
164 },
165 };
166}
167
168afterEach(async () => {
169 gitStatusListeners.clear();
170 serviceHarness.connections.clear();
171 serviceHarness.listeners.clear();
172 gitClient.onStatus.mockClear();
173 gitClient.refreshStatus.mockClear();
174 resetGitStatusStateForTests();
175 resetAppAtomRegistryForTests();
176});
177
178describe("gitStatusState", () => {
179 it("starts fresh cwd state in a pending state", () => {
180 expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({
181 data: null,
182 error: null,
183 cause: null,
184 isPending: true,
185 });
186 });
187
188 it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => {
189 const releaseA = watchGitStatus(TARGET, gitClient);
190 const releaseB = watchGitStatus(TARGET, gitClient);
191
192 expect(gitClient.onStatus).toHaveBeenCalledOnce();
193 expect(getGitStatusSnapshot(TARGET)).toEqual({
194 data: null,
195 error: null,
196 cause: null,
197 isPending: true,
198 });
199
200 emitGitStatus(BASE_STATUS);
201
202 expect(getGitStatusSnapshot(TARGET)).toEqual({
203 data: BASE_STATUS,
204 error: null,
205 cause: null,
206 isPending: false,
207 });
208
209 releaseA();
210 expect(gitStatusListeners.size).toBe(1);
211
212 releaseB();
213 expect(gitStatusListeners.size).toBe(0);
214 });
215
216 it("refreshes git status through the unary RPC without restarting the stream", async () => {
217 const release = watchGitStatus(TARGET, gitClient);
218
219 emitGitStatus(BASE_STATUS);
220 const refreshed = await refreshGitStatus(TARGET, gitClient);
221
222 expect(gitClient.onStatus).toHaveBeenCalledOnce();
223 expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
224 expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" });
225 expect(getGitStatusSnapshot(TARGET)).toEqual({
226 data: BASE_STATUS,
227 error: null,
228 cause: null,
229 isPending: false,
230 });
231
232 release();
233 });
234
235 it("keeps git status subscriptions isolated by environment when cwds match", () => {
236 const localListeners = new Set<(event: VcsStatusResult) => void>();
237 const remoteListeners = new Set<(event: VcsStatusResult) => void>();
238 const localClient = {
239 refreshStatus: vi.fn(),
240 onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
241 registerListener(localListeners, listener),
242 ),
243 };
244 const remoteClient = {
245 refreshStatus: vi.fn(),
246 onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
247 registerListener(remoteListeners, listener),
248 ),
249 };
250 const remoteTarget = { environmentId: OTHER_ENVIRONMENT_ID, cwd: "/repo" } as const;
251
252 const releaseLocal = watchGitStatus(TARGET, localClient);
253 const releaseRemote = watchGitStatus(remoteTarget, remoteClient);
254
255 for (const listener of localListeners) {
256 listener(BASE_STATUS);
257 }
258 for (const listener of remoteListeners) {
259 listener({ ...BASE_STATUS, refName: "remote-refName" });
260 }
261
262 expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status");
263 expect(getGitStatusSnapshot(remoteTarget).data?.refName).toBe("remote-refName");
264
265 releaseLocal();
266 releaseRemote();
267 });
268
269 it("waits for a delayed environment client registration instead of throwing", () => {
270 const release = watchGitStatus(TARGET);
271
272 expect(getGitStatusSnapshot(TARGET)).toEqual({
273 data: null,
274 error: null,
275 cause: null,
276 isPending: true,
277 });
278
279 const registered = createRegisteredGitStatusClient(ENVIRONMENT_ID);
280 registered.emit(BASE_STATUS);
281
282 expect(getGitStatusSnapshot(TARGET)).toEqual({
283 data: BASE_STATUS,
284 error: null,
285 cause: null,
286 isPending: false,
287 });
288
289 release();
290 });
291
292 it("resubscribes after the environment client is removed and re-registered", async () => {
293 const firstClient = createRegisteredGitStatusClient(ENVIRONMENT_ID);
294 const release = watchGitStatus(TARGET);
295
296 firstClient.emit(BASE_STATUS);
297 expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status");
298
299 serviceHarness.connections.delete(ENVIRONMENT_ID);
300 for (const listener of serviceHarness.listeners) {
301 listener();
302 }
303
304 expect(getGitStatusSnapshot(TARGET)).toEqual({
305 data: BASE_STATUS,
306 error: null,
307 cause: null,
308 isPending: true,
309 });
310
311 const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID);
312 secondClient.emit({ ...BASE_STATUS, refName: "reconnected-refName" });
313
314 expect(getGitStatusSnapshot(TARGET)).toEqual({
315 data: { ...BASE_STATUS, refName: "reconnected-refName" },
316 error: null,
317 cause: null,
318 isPending: false,
319 });
320
321 release();
322 });
323
324 it("returns the cached snapshot when refresh is requested before the client is registered", async () => {
325 await expect(refreshGitStatus(TARGET)).resolves.toBeNull();
326 });
327});
apps/web/src/lib/gitStatusState.ts deleted-287
...@@ -1,287 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type EnvironmentId,
4 type GitManagerServiceError,
5 type VcsStatusResult,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { Atom } from "effect/unstable/reactivity";
9import { useEffect } from "react";
10
11import { appAtomRegistry } from "../rpc/atomRegistry";
12import {
13 readEnvironmentConnection,
14 subscribeEnvironmentConnections,
15} from "../environments/runtime";
16import type { WsRpcClient } from "~/rpc/wsRpcClient";
17
18interface GitStatusState {
19 readonly data: VcsStatusResult | null;
20 readonly error: GitManagerServiceError | null;
21 readonly cause: Cause.Cause<GitManagerServiceError> | null;
22 readonly isPending: boolean;
23}
24
25type GitStatusClient = Pick<WsRpcClient["vcs"], "onStatus" | "refreshStatus">;
26interface ResolvedGitStatusClient {
27 readonly clientIdentity: string;
28 readonly client: GitStatusClient;
29}
30
31interface WatchedGitStatus {
32 refCount: number;
33 unsubscribe: () => void;
34}
35
36interface GitStatusTarget {
37 readonly environmentId: EnvironmentId | null;
38 readonly cwd: string | null;
39}
40
41const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
42 data: null,
43 error: null,
44 cause: null,
45 isPending: false,
46});
47const INITIAL_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
48 ...EMPTY_GIT_STATUS_STATE,
49 isPending: true,
50});
51const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe(
52 Atom.keepAlive,
53 Atom.withLabel("git-status:null"),
54);
55
56const NOOP: () => void = () => undefined;
57const watchedGitStatuses = new Map<string, WatchedGitStatus>();
58const knownGitStatusKeys = new Set<string>();
59const gitStatusRefreshInFlight = new Map<string, Promise<VcsStatusResult>>();
60const gitStatusLastRefreshAtByKey = new Map<string, number>();
61
62const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
63
64const gitStatusStateAtom = Atom.family((key: string) => {
65 knownGitStatusKeys.add(key);
66 return Atom.make(INITIAL_GIT_STATUS_STATE).pipe(
67 Atom.keepAlive,
68 Atom.withLabel(`git-status:${key}`),
69 );
70});
71
72function getGitStatusTargetKey(target: GitStatusTarget): string | null {
73 if (target.environmentId === null || target.cwd === null) {
74 return null;
75 }
76
77 return `${target.environmentId}:${target.cwd}`;
78}
79
80function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatusClient | null {
81 if (target.environmentId === null) {
82 return null;
83 }
84 const connection = readEnvironmentConnection(target.environmentId);
85 return connection
86 ? { clientIdentity: connection.environmentId, client: connection.client.vcs }
87 : null;
88}
89
90export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState {
91 const targetKey = getGitStatusTargetKey(target);
92 if (targetKey === null) {
93 return EMPTY_GIT_STATUS_STATE;
94 }
95
96 return appAtomRegistry.get(gitStatusStateAtom(targetKey));
97}
98
99export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient): () => void {
100 const targetKey = getGitStatusTargetKey(target);
101 if (targetKey === null) {
102 return NOOP;
103 }
104
105 const watched = watchedGitStatuses.get(targetKey);
106 if (watched) {
107 watched.refCount += 1;
108 return () => unwatchGitStatus(targetKey);
109 }
110
111 watchedGitStatuses.set(targetKey, {
112 refCount: 1,
113 unsubscribe: subscribeToGitStatusTarget(targetKey, target, client),
114 });
115
116 return () => unwatchGitStatus(targetKey);
117}
118
119export function refreshGitStatus(
120 target: GitStatusTarget,
121 client?: GitStatusClient,
122): Promise<VcsStatusResult | null> {
123 const targetKey = getGitStatusTargetKey(target);
124 if (targetKey === null || target.cwd === null) {
125 return Promise.resolve(null);
126 }
127
128 const resolvedClient = client ?? readResolvedGitStatusClient(target)?.client;
129 if (!resolvedClient) {
130 return Promise.resolve(getGitStatusSnapshot(target).data);
131 }
132
133 const currentInFlight = gitStatusRefreshInFlight.get(targetKey);
134 if (currentInFlight) {
135 return currentInFlight;
136 }
137
138 const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0;
139 if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) {
140 return Promise.resolve(getGitStatusSnapshot(target).data);
141 }
142
143 gitStatusLastRefreshAtByKey.set(targetKey, Date.now());
144 const refreshPromise = resolvedClient.refreshStatus({ cwd: target.cwd }).finally(() => {
145 gitStatusRefreshInFlight.delete(targetKey);
146 });
147 gitStatusRefreshInFlight.set(targetKey, refreshPromise);
148 return refreshPromise;
149}
150
151export function resetGitStatusStateForTests(): void {
152 for (const watched of watchedGitStatuses.values()) {
153 watched.unsubscribe();
154 }
155 watchedGitStatuses.clear();
156 gitStatusRefreshInFlight.clear();
157 gitStatusLastRefreshAtByKey.clear();
158
159 for (const key of knownGitStatusKeys) {
160 appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE);
161 }
162 knownGitStatusKeys.clear();
163}
164
165export function useGitStatus(target: GitStatusTarget): GitStatusState {
166 const targetKey = getGitStatusTargetKey(target);
167 useEffect(
168 () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }),
169 [target.environmentId, target.cwd],
170 );
171
172 const state = useAtomValue(
173 targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM,
174 );
175 return targetKey === null ? EMPTY_GIT_STATUS_STATE : state;
176}
177
178function unwatchGitStatus(targetKey: string): void {
179 const watched = watchedGitStatuses.get(targetKey);
180 if (!watched) {
181 return;
182 }
183
184 watched.refCount -= 1;
185 if (watched.refCount > 0) {
186 return;
187 }
188
189 watched.unsubscribe();
190 watchedGitStatuses.delete(targetKey);
191}
192
193function subscribeToGitStatusTarget(
194 targetKey: string,
195 target: GitStatusTarget,
196 providedClient?: GitStatusClient,
197): () => void {
198 if (target.cwd === null) {
199 return NOOP;
200 }
201
202 const cwd = target.cwd;
203 let currentClientIdentity: string | null = null;
204 let currentUnsubscribe = NOOP;
205
206 const syncClientSubscription = () => {
207 const resolved = providedClient
208 ? {
209 clientIdentity: `provided:${targetKey}`,
210 client: providedClient,
211 }
212 : readResolvedGitStatusClient(target);
213
214 if (!resolved) {
215 if (currentClientIdentity !== null) {
216 currentUnsubscribe();
217 currentUnsubscribe = NOOP;
218 currentClientIdentity = null;
219 }
220 markGitStatusPending(targetKey);
221 return;
222 }
223
224 if (currentClientIdentity === resolved.clientIdentity) {
225 return;
226 }
227
228 currentUnsubscribe();
229 currentClientIdentity = resolved.clientIdentity;
230 currentUnsubscribe = subscribeToGitStatus(targetKey, cwd, resolved.client);
231 };
232
233 const unsubscribeRegistry = providedClient
234 ? NOOP
235 : subscribeEnvironmentConnections(syncClientSubscription);
236 syncClientSubscription();
237
238 return () => {
239 unsubscribeRegistry();
240 currentUnsubscribe();
241 };
242}
243
244function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void {
245 markGitStatusPending(targetKey);
246 return client.onStatus(
247 { cwd },
248 (status: VcsStatusResult) => {
249 appAtomRegistry.set(gitStatusStateAtom(targetKey), {
250 data: status,
251 error: null,
252 cause: null,
253 isPending: false,
254 });
255 },
256 {
257 onResubscribe: () => {
258 markGitStatusPending(targetKey);
259 },
260 },
261 );
262}
263
264function markGitStatusPending(targetKey: string): void {
265 const atom = gitStatusStateAtom(targetKey);
266 const current = appAtomRegistry.get(atom);
267 const next =
268 current.data === null
269 ? INITIAL_GIT_STATUS_STATE
270 : {
271 ...current,
272 error: null,
273 cause: null,
274 isPending: true,
275 };
276
277 if (
278 current.data === next.data &&
279 current.error === next.error &&
280 current.cause === next.cause &&
281 current.isPending === next.isPending
282 ) {
283 return;
284 }
285
286 appAtomRegistry.set(atom, next);
287}
apps/web/src/lib/projectPaths.ts+17-259
...@@ -1,259 +1,17 @@...@@ -1,259 +1,17 @@
1import {1export {
2 isExplicitRelativePath,2 appendBrowsePathSegment,
3 isUncPath,3 canNavigateUp,
4 isWindowsAbsolutePath,4 ensureBrowseDirectoryPath,
5 isWindowsDrivePath,5 findProjectByPath,
6} from "@t3tools/shared/path";6 getBrowseDirectoryPath,
7import { isWindowsPlatform } from "./utils";7 getBrowseLeafPathSegment,
88 getBrowseParentPath,
9function isRootPath(value: string): boolean {9 hasTrailingPathSeparator,
10 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);10 inferProjectTitleFromPath,
11}11 isExplicitRelativeProjectPath,
1212 isFilesystemBrowseQuery,
13function getAbsolutePathKind(value: string): "unix" | "windows" | null {13 isUnsupportedWindowsProjectPath,
14 if (isWindowsDrivePath(value) || isUncPath(value)) {14 normalizeProjectPathForComparison,
15 return "windows";15 normalizeProjectPathForDispatch,
16 }16 resolveProjectPathForDispatch,
1717} from "@t3tools/client-runtime";
18 if (value.startsWith("/")) {
19 return "unix";
20 }
21
22 return null;
23}
24
25function trimTrailingPathSeparators(value: string): string {
26 if (value.length === 0 || isRootPath(value)) {
27 return value;
28 }
29
30 const trimmed =
31 getAbsolutePathKind(value) === "unix"
32 ? value.replace(/\/+$/g, "")
33 : value.replace(/[\\/]+$/g, "");
34 if (trimmed.length === 0) {
35 return value;
36 }
37
38 return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
39}
40
41function preferredPathSeparator(value: string): "/" | "\\" {
42 const absolutePathKind = getAbsolutePathKind(value);
43 if (absolutePathKind === "windows") {
44 return "\\";
45 }
46 if (absolutePathKind === "unix") {
47 return "/";
48 }
49
50 return value.includes("\\") ? "\\" : "/";
51}
52
53export function hasTrailingPathSeparator(value: string): boolean {
54 return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value);
55}
56
57export { isExplicitRelativePath as isExplicitRelativeProjectPath };
58
59function splitPathSegments(value: string, separator: "/" | "\\"): string[] {
60 return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean);
61}
62
63function getLastPathSeparatorIndex(value: string): number {
64 if (getAbsolutePathKind(value) === "unix") {
65 return value.lastIndexOf("/");
66 }
67
68 return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
69}
70
71function splitAbsolutePath(value: string): {
72 root: string;
73 separator: "/" | "\\";
74 segments: string[];
75} | null {
76 if (isWindowsDrivePath(value)) {
77 const root = `${value.slice(0, 2)}\\`;
78 const segments = splitPathSegments(value.slice(root.length), "\\");
79 return { root, separator: "\\", segments };
80 }
81 if (isUncPath(value)) {
82 const segments = splitPathSegments(value, "\\");
83 const [server, share, ...rest] = segments;
84 if (!server || !share) {
85 return null;
86 }
87 return {
88 root: `\\\\${server}\\${share}\\`,
89 separator: "\\",
90 segments: rest,
91 };
92 }
93 if (value.startsWith("/")) {
94 return {
95 root: "/",
96 separator: "/",
97 segments: splitPathSegments(value.slice(1), "/"),
98 };
99 }
100 return null;
101}
102
103export function isFilesystemBrowseQuery(
104 value: string,
105 platform = typeof navigator === "undefined" ? "" : navigator.platform,
106): boolean {
107 const allowWindowsPaths = isWindowsPlatform(platform);
108 return (
109 value.startsWith("./") ||
110 value.startsWith("../") ||
111 value.startsWith(".\\") ||
112 value.startsWith("..\\") ||
113 value.startsWith("/") ||
114 value.startsWith("~/") ||
115 (allowWindowsPaths && isWindowsAbsolutePath(value))
116 );
117}
118
119export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean {
120 return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
121}
122
123export function normalizeProjectPathForDispatch(value: string): string {
124 return trimTrailingPathSeparators(value.trim());
125}
126
127export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {
128 const trimmedValue = value.trim();
129 if (!isExplicitRelativePath(trimmedValue) || !cwd) {
130 return normalizeProjectPathForDispatch(trimmedValue);
131 }
132
133 const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd));
134 if (!absoluteBase) {
135 return normalizeProjectPathForDispatch(trimmedValue);
136 }
137
138 const nextSegments = [...absoluteBase.segments];
139 for (const segment of trimmedValue.split(/[\\/]+/)) {
140 if (segment.length === 0 || segment === ".") {
141 continue;
142 }
143 if (segment === "..") {
144 nextSegments.pop();
145 continue;
146 }
147 nextSegments.push(segment);
148 }
149
150 const joinedPath = nextSegments.join(absoluteBase.separator);
151 if (joinedPath.length === 0) {
152 return normalizeProjectPathForDispatch(absoluteBase.root);
153 }
154
155 return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`);
156}
157
158export function normalizeProjectPathForComparison(value: string): string {
159 const normalized = normalizeProjectPathForDispatch(value);
160 if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) {
161 return normalized.replaceAll("/", "\\").toLowerCase();
162 }
163 return normalized;
164}
165
166export function findProjectByPath<T extends { cwd: string }>(
167 projects: ReadonlyArray<T>,
168 candidatePath: string,
169): T | undefined {
170 const normalizedCandidate = normalizeProjectPathForComparison(candidatePath);
171 if (normalizedCandidate.length === 0) {
172 return undefined;
173 }
174
175 return projects.find(
176 (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate,
177 );
178}
179
180export function inferProjectTitleFromPath(value: string): string {
181 const normalized = normalizeProjectPathForDispatch(value);
182 const absolutePath = splitAbsolutePath(normalized);
183 if (absolutePath) {
184 return absolutePath.segments.findLast(Boolean) ?? normalized;
185 }
186
187 const segments = normalized.split(/[/\\]/);
188 return segments.findLast(Boolean) ?? normalized;
189}
190
191export function appendBrowsePathSegment(currentPath: string, segment: string): string {
192 const separator = preferredPathSeparator(currentPath);
193 return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`;
194}
195
196export function getBrowseLeafPathSegment(currentPath: string): string {
197 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
198 return currentPath.slice(lastSeparatorIndex + 1);
199}
200
201export function getBrowseDirectoryPath(currentPath: string): string {
202 if (hasTrailingPathSeparator(currentPath)) {
203 return currentPath;
204 }
205
206 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
207 if (lastSeparatorIndex < 0) {
208 return currentPath;
209 }
210
211 return currentPath.slice(0, lastSeparatorIndex + 1);
212}
213
214export function ensureBrowseDirectoryPath(currentPath: string): string {
215 const trimmed = currentPath.trim();
216 if (trimmed.length === 0) {
217 return trimmed;
218 }
219
220 if (hasTrailingPathSeparator(trimmed)) {
221 return trimmed;
222 }
223
224 return `${trimmed}${preferredPathSeparator(trimmed)}`;
225}
226
227export function getBrowseParentPath(currentPath: string): string | null {
228 const trimmed = trimTrailingPathSeparators(currentPath);
229 const absolutePath = splitAbsolutePath(trimmed);
230 if (absolutePath) {
231 if (absolutePath.segments.length === 0) {
232 return null;
233 }
234
235 if (absolutePath.segments.length === 1) {
236 return absolutePath.root;
237 }
238
239 const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator);
240 return `${absolutePath.root}${parentSegments}${absolutePath.separator}`;
241 }
242
243 const separator = preferredPathSeparator(currentPath);
244 const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed);
245
246 if (lastSeparatorIndex < 0) {
247 return null;
248 }
249
250 if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) {
251 return `${trimmed.slice(0, 2)}${separator}`;
252 }
253
254 return trimmed.slice(0, lastSeparatorIndex + 1);
255}
256
257export function canNavigateUp(currentPath: string): boolean {
258 return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null;
259}
apps/web/src/lib/projectReactQuery.ts deleted-52
...@@ -1,52 +0,0 @@
1import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";
3import { ensureEnvironmentApi } from "~/environmentApi";
4
5export const projectQueryKeys = {
6 all: ["projects"] as const,
7 searchEntries: (
8 environmentId: EnvironmentId | null,
9 cwd: string | null,
10 query: string,
11 limit: number,
12 ) => ["projects", "search-entries", environmentId ?? null, cwd, query, limit] as const,
13};
14
15const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
16const DEFAULT_SEARCH_ENTRIES_STALE_TIME = 15_000;
17const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
18 entries: [],
19 truncated: false,
20};
21
22export function projectSearchEntriesQueryOptions(input: {
23 environmentId: EnvironmentId | null;
24 cwd: string | null;
25 query: string;
26 enabled?: boolean;
27 limit?: number;
28 staleTime?: number;
29}) {
30 const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT;
31 return queryOptions({
32 queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit),
33 queryFn: async () => {
34 if (!input.cwd || !input.environmentId) {
35 throw new Error("Workspace entry search is unavailable.");
36 }
37 const api = ensureEnvironmentApi(input.environmentId);
38 return api.projects.searchEntries({
39 cwd: input.cwd,
40 query: input.query,
41 limit,
42 });
43 },
44 enabled:
45 (input.enabled ?? true) &&
46 input.environmentId !== null &&
47 input.cwd !== null &&
48 input.query.length > 0,
49 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
50 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
51 });
52}
apps/web/src/lib/providerReactQuery.test.ts deleted-224
...@@ -1,224 +0,0 @@
1import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts";
2import { QueryClient } from "@tanstack/react-query";
3import { afterEach, describe, expect, it, vi } from "vitest";
4import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";
5import * as environmentApi from "../environmentApi";
6
7const threadId = ThreadId.make("thread-id");
8const environmentId = EnvironmentId.make("environment-local");
9
10function mockNativeApi(input: {
11 getTurnDiff: ReturnType<typeof vi.fn>;
12 getFullThreadDiff: ReturnType<typeof vi.fn>;
13}) {
14 vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({
15 orchestration: {
16 getTurnDiff: input.getTurnDiff,
17 getFullThreadDiff: input.getFullThreadDiff,
18 },
19 } as unknown as EnvironmentApi);
20}
21
22afterEach(() => {
23 vi.restoreAllMocks();
24});
25
26describe("providerQueryKeys.checkpointDiff", () => {
27 it("includes cacheScope so reused turn counts do not collide", () => {
28 const baseInput = {
29 environmentId,
30 threadId,
31 fromTurnCount: 1,
32 toTurnCount: 2,
33 ignoreWhitespace: false,
34 } as const;
35
36 expect(
37 providerQueryKeys.checkpointDiff({
38 ...baseInput,
39 cacheScope: "turn:old-turn",
40 }),
41 ).not.toEqual(
42 providerQueryKeys.checkpointDiff({
43 ...baseInput,
44 cacheScope: "turn:new-turn",
45 }),
46 );
47 });
48
49 it("includes ignoreWhitespace so normal and whitespace-hidden diffs do not collide", () => {
50 const baseInput = {
51 environmentId,
52 threadId,
53 fromTurnCount: 1,
54 toTurnCount: 2,
55 cacheScope: "turn:abc",
56 } as const;
57
58 expect(
59 providerQueryKeys.checkpointDiff({
60 ...baseInput,
61 ignoreWhitespace: false,
62 }),
63 ).not.toEqual(
64 providerQueryKeys.checkpointDiff({
65 ...baseInput,
66 ignoreWhitespace: true,
67 }),
68 );
69 });
70});
71
72describe("checkpointDiffQueryOptions", () => {
73 it("forwards checkpoint range to the provider API by default", async () => {
74 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
75 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
76 mockNativeApi({ getTurnDiff, getFullThreadDiff });
77
78 const options = checkpointDiffQueryOptions({
79 environmentId,
80 threadId,
81 fromTurnCount: 3,
82 toTurnCount: 4,
83 ignoreWhitespace: false,
84 cacheScope: "turn:abc",
85 });
86
87 const queryClient = new QueryClient();
88 await queryClient.fetchQuery(options);
89
90 expect(getTurnDiff).toHaveBeenCalledWith({
91 threadId,
92 fromTurnCount: 3,
93 toTurnCount: 4,
94 ignoreWhitespace: false,
95 });
96 expect(getFullThreadDiff).not.toHaveBeenCalled();
97 });
98
99 it("forwards whitespace-hidden checkpoint range to the provider API", async () => {
100 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
101 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
102 mockNativeApi({ getTurnDiff, getFullThreadDiff });
103
104 const options = checkpointDiffQueryOptions({
105 environmentId,
106 threadId,
107 fromTurnCount: 3,
108 toTurnCount: 4,
109 ignoreWhitespace: true,
110 cacheScope: "turn:abc",
111 });
112
113 const queryClient = new QueryClient();
114 await queryClient.fetchQuery(options);
115
116 expect(getTurnDiff).toHaveBeenCalledWith({
117 threadId,
118 fromTurnCount: 3,
119 toTurnCount: 4,
120 ignoreWhitespace: true,
121 });
122 expect(getFullThreadDiff).not.toHaveBeenCalled();
123 });
124
125 it("uses explicit full thread diff API when range starts from zero", async () => {
126 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
127 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
128 mockNativeApi({ getTurnDiff, getFullThreadDiff });
129
130 const options = checkpointDiffQueryOptions({
131 environmentId,
132 threadId,
133 fromTurnCount: 0,
134 toTurnCount: 2,
135 ignoreWhitespace: true,
136 cacheScope: "thread:all",
137 });
138
139 const queryClient = new QueryClient();
140 await queryClient.fetchQuery(options);
141
142 expect(getFullThreadDiff).toHaveBeenCalledWith({
143 threadId,
144 toTurnCount: 2,
145 ignoreWhitespace: true,
146 });
147 expect(getTurnDiff).not.toHaveBeenCalled();
148 });
149
150 it("fails fast on invalid range and does not call provider RPC", async () => {
151 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
152 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
153 mockNativeApi({ getTurnDiff, getFullThreadDiff });
154
155 const options = checkpointDiffQueryOptions({
156 environmentId,
157 threadId,
158 fromTurnCount: 4,
159 toTurnCount: 3,
160 ignoreWhitespace: false,
161 cacheScope: "turn:invalid",
162 });
163
164 const queryClient = new QueryClient();
165
166 await expect(queryClient.fetchQuery(options)).rejects.toThrow(
167 "Checkpoint diff is unavailable.",
168 );
169 expect(getTurnDiff).not.toHaveBeenCalled();
170 expect(getFullThreadDiff).not.toHaveBeenCalled();
171 });
172
173 it("retries checkpoint-not-ready errors longer than generic failures", () => {
174 const options = checkpointDiffQueryOptions({
175 environmentId,
176 threadId,
177 fromTurnCount: 1,
178 toTurnCount: 2,
179 ignoreWhitespace: false,
180 cacheScope: "turn:abc",
181 });
182 const retry = options.retry;
183 expect(typeof retry).toBe("function");
184 if (typeof retry !== "function") {
185 throw new Error("Expected retry to be a function.");
186 }
187
188 expect(retry(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe(true);
189 expect(
190 retry(11, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")),
191 ).toBe(true);
192 expect(
193 retry(12, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")),
194 ).toBe(false);
195 expect(retry(2, new Error("Something else failed."))).toBe(true);
196 expect(retry(3, new Error("Something else failed."))).toBe(false);
197 });
198
199 it("backs off longer for checkpoint-not-ready errors", () => {
200 const options = checkpointDiffQueryOptions({
201 environmentId,
202 threadId,
203 fromTurnCount: 1,
204 toTurnCount: 2,
205 ignoreWhitespace: false,
206 cacheScope: "turn:abc",
207 });
208 const retryDelay = options.retryDelay;
209 expect(typeof retryDelay).toBe("function");
210 if (typeof retryDelay !== "function") {
211 throw new Error("Expected retryDelay to be a function.");
212 }
213
214 const checkpointDelay = retryDelay(
215 4,
216 new Error("Checkpoint turn count 2 exceeds current turn count 1."),
217 );
218 const genericDelay = retryDelay(4, new Error("Network failure"));
219
220 expect(typeof checkpointDelay).toBe("number");
221 expect(typeof genericDelay).toBe("number");
222 expect((checkpointDelay ?? 0) > (genericDelay ?? 0)).toBe(true);
223 });
224});
apps/web/src/lib/providerReactQuery.ts deleted-139
...@@ -1,139 +0,0 @@
1import {
2 type EnvironmentId,
3 OrchestrationGetFullThreadDiffInput,
4 OrchestrationGetTurnDiffInput,
5 ThreadId,
6} from "@t3tools/contracts";
7import { queryOptions } from "@tanstack/react-query";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import { ensureEnvironmentApi } from "../environmentApi";
11
12const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput);
13const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput);
14
15interface CheckpointDiffQueryInput {
16 environmentId: EnvironmentId | null;
17 threadId: ThreadId | null;
18 fromTurnCount: number | null;
19 toTurnCount: number | null;
20 ignoreWhitespace: boolean;
21 cacheScope?: string | null;
22 enabled?: boolean;
23}
24
25export const providerQueryKeys = {
26 all: ["providers"] as const,
27 checkpointDiff: (input: CheckpointDiffQueryInput) =>
28 [
29 "providers",
30 "checkpointDiff",
31 input.environmentId ?? null,
32 input.threadId,
33 input.fromTurnCount,
34 input.toTurnCount,
35 input.ignoreWhitespace,
36 input.cacheScope ?? null,
37 ] as const,
38};
39
40function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) {
41 if (input.fromTurnCount === 0) {
42 return decodeFullThreadDiffInput({
43 threadId: input.threadId,
44 toTurnCount: input.toTurnCount,
45 ignoreWhitespace: input.ignoreWhitespace,
46 }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields })));
47 }
48
49 return decodeTurnDiffInput({
50 threadId: input.threadId,
51 fromTurnCount: input.fromTurnCount,
52 toTurnCount: input.toTurnCount,
53 ignoreWhitespace: input.ignoreWhitespace,
54 }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields })));
55}
56
57function asCheckpointErrorMessage(error: unknown): string {
58 if (error instanceof Error) {
59 return error.message;
60 }
61 if (typeof error === "string") {
62 return error;
63 }
64 return "";
65}
66
67function normalizeCheckpointErrorMessage(error: unknown): string {
68 const message = asCheckpointErrorMessage(error).trim();
69 if (message.length === 0) {
70 return "Failed to load checkpoint diff.";
71 }
72
73 const lower = message.toLowerCase();
74 if (lower.includes("not a git repository")) {
75 return "Turn diffs are unavailable because this project is not a git repository.";
76 }
77
78 if (
79 lower.includes("checkpoint unavailable for thread") ||
80 lower.includes("checkpoint invariant violation")
81 ) {
82 const separatorIndex = message.indexOf(":");
83 if (separatorIndex >= 0) {
84 const detail = message.slice(separatorIndex + 1).trim();
85 if (detail.length > 0) {
86 return detail;
87 }
88 }
89 }
90
91 return message;
92}
93
94function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
95 const message = asCheckpointErrorMessage(error).toLowerCase();
96 return (
97 message.includes("exceeds current turn count") ||
98 message.includes("checkpoint is unavailable for turn") ||
99 message.includes("filesystem checkpoint is unavailable")
100 );
101}
102
103export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
104 const decodedRequest = decodeCheckpointDiffRequest(input);
105
106 return queryOptions({
107 queryKey: providerQueryKeys.checkpointDiff(input),
108 queryFn: async () => {
109 if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") {
110 throw new Error("Checkpoint diff is unavailable.");
111 }
112 const api = ensureEnvironmentApi(input.environmentId);
113 try {
114 if (decodedRequest.value.kind === "fullThreadDiff") {
115 return await api.orchestration.getFullThreadDiff(decodedRequest.value.input);
116 }
117 return await api.orchestration.getTurnDiff(decodedRequest.value.input);
118 } catch (error) {
119 throw new Error(normalizeCheckpointErrorMessage(error), { cause: error });
120 }
121 },
122 enabled:
123 (input.enabled ?? true) &&
124 !!input.environmentId &&
125 !!input.threadId &&
126 decodedRequest._tag === "Some",
127 staleTime: Infinity,
128 retry: (failureCount, error) => {
129 if (isCheckpointTemporarilyUnavailable(error)) {
130 return failureCount < 12;
131 }
132 return failureCount < 3;
133 },
134 retryDelay: (attempt, error) =>
135 isCheckpointTemporarilyUnavailable(error)
136 ? Math.min(5_000, 250 * 2 ** (attempt - 1))
137 : Math.min(1_000, 100 * 2 ** (attempt - 1)),
138 });
139}
apps/web/src/lib/runtime.ts created+5
...@@ -0,0 +1,5 @@
1import * as ManagedRuntime from "effect/ManagedRuntime";
2
3import { remoteHttpClientLayer } from "@t3tools/client-runtime";
4
5export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch));
apps/web/src/lib/sourceControlActions.ts created+477
...@@ -0,0 +1,477 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type VcsActionOperation,
4 type VcsActionState,
5 EMPTY_VCS_ACTION_ATOM,
6 EMPTY_VCS_ACTION_STATE,
7 createVcsActionManager,
8 getVcsActionTargetKey,
9 vcsActionStateAtom,
10} from "@t3tools/client-runtime";
11import {
12 type EnvironmentId,
13 type GitActionProgressEvent,
14 type GitRunStackedActionResult,
15 type GitStackedAction,
16 type GitResolvePullRequestResult,
17 type SourceControlCloneProtocol,
18 type SourceControlPublishRepositoryResult,
19 type SourceControlRepositoryVisibility,
20 type ThreadId,
21 type VcsPullResult,
22} from "@t3tools/contracts";
23import {
24 useCallback,
25 useEffect,
26 useMemo,
27 useState,
28 useSyncExternalStore,
29 useTransition,
30} from "react";
31
32import { ensureEnvironmentApi } from "../environmentApi";
33import { readEnvironmentConnection } from "../environments/runtime";
34import { appAtomRegistry } from "../rpc/atomRegistry";
35import { getVcsStatusSnapshot, refreshVcsStatus } from "./vcsStatusState";
36import { vcsRefManager } from "./vcsRefState";
37
38type SourceControlActionKind =
39 | "init"
40 | "pull"
41 | "publishRepository"
42 | "runStackedAction"
43 | "preparePullRequestThread";
44
45interface SourceControlActionScope {
46 readonly environmentId: EnvironmentId | null;
47 readonly cwd: string | null;
48}
49
50interface SourceControlActionState<TArgs extends ReadonlyArray<unknown>, TResult> {
51 readonly isPending: boolean;
52 readonly error: unknown;
53 readonly run: (...args: TArgs) => Promise<TResult>;
54 readonly resetError: () => void;
55}
56
57export const vcsActionManager = createVcsActionManager({
58 getRegistry: () => appAtomRegistry,
59 getClient: (environmentId) => {
60 const client = readEnvironmentConnection(environmentId)?.client;
61 return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null;
62 },
63 onInvalidate: (target) => invalidateSourceControlState(target),
64});
65
66const actionListeners = new Set<() => void>();
67const activeActionCounts = new Map<string, number>();
68
69function notifyActionListeners(): void {
70 for (const listener of actionListeners) {
71 listener();
72 }
73}
74
75function subscribeActionState(listener: () => void): () => void {
76 actionListeners.add(listener);
77 return () => {
78 actionListeners.delete(listener);
79 };
80}
81
82function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string {
83 return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`;
84}
85
86function beginAction(key: string): () => void {
87 activeActionCounts.set(key, (activeActionCounts.get(key) ?? 0) + 1);
88 notifyActionListeners();
89 let ended = false;
90 return () => {
91 if (ended) {
92 return;
93 }
94 ended = true;
95 const next = (activeActionCounts.get(key) ?? 1) - 1;
96 if (next <= 0) {
97 activeActionCounts.delete(key);
98 } else {
99 activeActionCounts.set(key, next);
100 }
101 notifyActionListeners();
102 };
103}
104
105function isAnyActionRunning(
106 kinds: ReadonlyArray<SourceControlActionKind>,
107 scope: SourceControlActionScope,
108): boolean {
109 return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0);
110}
111
112function getVcsActionOperationForKind(kind: SourceControlActionKind): VcsActionOperation | null {
113 switch (kind) {
114 case "init":
115 return "init";
116 case "pull":
117 return "pull";
118 case "runStackedAction":
119 return "run_change_request";
120 case "publishRepository":
121 case "preparePullRequestThread":
122 return null;
123 }
124}
125
126function useVcsActionStateForScope(scope: SourceControlActionScope): VcsActionState {
127 const targetKey = getVcsActionTargetKey(scope);
128 const state = useAtomValue(
129 targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM,
130 );
131 return targetKey === null ? EMPTY_VCS_ACTION_STATE : state;
132}
133
134export function invalidateSourceControlState(scope?: {
135 readonly environmentId?: EnvironmentId | null;
136 readonly cwd?: string | null;
137}): Promise<void> {
138 const environmentId = scope?.environmentId ?? null;
139 const cwd = scope?.cwd ?? null;
140 if (cwd !== null) {
141 vcsRefManager.invalidateScope({ environmentId, cwd });
142 if (environmentId !== null) {
143 return refreshVcsStatus({ environmentId, cwd }).then(
144 () => undefined,
145 () => undefined,
146 );
147 }
148 return Promise.resolve();
149 }
150
151 vcsRefManager.invalidate();
152 return Promise.resolve();
153}
154
155function useSourceControlAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: {
156 readonly kind: SourceControlActionKind;
157 readonly scope: SourceControlActionScope;
158 readonly action: (...args: TArgs) => Promise<TResult>;
159 readonly invalidateOnSuccess?: boolean;
160}): SourceControlActionState<TArgs, TResult> {
161 const { action, invalidateOnSuccess = true, kind, scope } = input;
162 const [error, setError] = useState<unknown>(null);
163 const [activeCount, setActiveCount] = useState(0);
164 const [isTransitionPending, startTransition] = useTransition();
165 const key = actionKey(kind, scope);
166
167 const resetError = useCallback(() => {
168 startTransition(() => setError(null));
169 }, [startTransition]);
170
171 const run = useCallback(
172 async (...args: TArgs): Promise<TResult> => {
173 const endAction = beginAction(key);
174 startTransition(() => {
175 setError(null);
176 setActiveCount((count) => count + 1);
177 });
178 try {
179 const result = await action(...args);
180 if (invalidateOnSuccess) {
181 await invalidateSourceControlState(scope);
182 }
183 return result;
184 } catch (nextError) {
185 startTransition(() => setError(nextError));
186 throw nextError;
187 } finally {
188 endAction();
189 startTransition(() => setActiveCount((count) => Math.max(0, count - 1)));
190 }
191 },
192 [action, invalidateOnSuccess, key, scope, startTransition],
193 );
194
195 return {
196 error,
197 isPending: activeCount > 0 || isTransitionPending,
198 resetError,
199 run,
200 };
201}
202
203export function useSourceControlActionRunning(
204 scope: SourceControlActionScope,
205 kinds: ReadonlyArray<SourceControlActionKind>,
206): boolean {
207 const stableKinds = useMemo(() => kinds.toSorted(), [kinds]);
208 const appActionRunning = useSyncExternalStore(
209 subscribeActionState,
210 () => isAnyActionRunning(stableKinds, scope),
211 () => false,
212 );
213 const vcsActionState = useVcsActionStateForScope(scope);
214 const vcsActionRunning =
215 vcsActionState.isRunning &&
216 stableKinds.some((kind) => getVcsActionOperationForKind(kind) === vcsActionState.operation);
217
218 return appActionRunning || vcsActionRunning;
219}
220
221function useVcsManagerAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: {
222 readonly operation: VcsActionOperation;
223 readonly scope: SourceControlActionScope;
224 readonly unavailableMessage: string;
225 readonly action: (...args: TArgs) => Promise<TResult | null>;
226}): SourceControlActionState<TArgs, TResult> {
227 const { action, operation, scope, unavailableMessage } = input;
228 const vcsActionState = useVcsActionStateForScope(scope);
229 const [error, setError] = useState<unknown>(null);
230 const [isTransitionPending, startTransition] = useTransition();
231
232 const resetError = useCallback(() => {
233 vcsActionManager.reset(scope);
234 startTransition(() => setError(null));
235 }, [scope, startTransition]);
236
237 const run = useCallback(
238 async (...args: TArgs): Promise<TResult> => {
239 startTransition(() => setError(null));
240 try {
241 const result = await action(...args);
242 if (result === null) {
243 throw new Error(unavailableMessage);
244 }
245 return result;
246 } catch (nextError) {
247 startTransition(() => setError(nextError));
248 throw nextError;
249 }
250 },
251 [action, startTransition, unavailableMessage],
252 );
253
254 return {
255 error: error ?? vcsActionState.error,
256 isPending:
257 isTransitionPending || (vcsActionState.isRunning && vcsActionState.operation === operation),
258 resetError,
259 run,
260 };
261}
262
263export function useVcsInitAction(scope: SourceControlActionScope) {
264 const action = useCallback(async () => {
265 if (!scope.cwd || !scope.environmentId) throw new Error("Git init is unavailable.");
266 return vcsActionManager.init(scope);
267 }, [scope]);
268
269 return useVcsManagerAction({
270 operation: "init",
271 scope,
272 unavailableMessage: "Git init is unavailable.",
273 action,
274 });
275}
276
277export function useGitStackedAction(scope: SourceControlActionScope) {
278 const action = useCallback(
279 async ({
280 actionId,
281 action,
282 commitMessage,
283 featureBranch,
284 filePaths,
285 onProgress,
286 }: {
287 actionId: string;
288 action: GitStackedAction;
289 commitMessage?: string;
290 featureBranch?: boolean;
291 filePaths?: string[];
292 onProgress?: (event: GitActionProgressEvent) => void;
293 }): Promise<GitRunStackedActionResult | null> => {
294 if (!scope.cwd || !scope.environmentId) throw new Error("Git action is unavailable.");
295 return vcsActionManager.runChangeRequest(
296 scope,
297 {
298 actionId,
299 action,
300 ...(commitMessage ? { commitMessage } : {}),
301 ...(featureBranch ? { featureBranch: true } : {}),
302 ...(filePaths && filePaths.length > 0 ? { filePaths } : {}),
303 },
304 {
305 gitStatus: getVcsStatusSnapshot(scope).data,
306 ...(onProgress ? { onProgress } : {}),
307 },
308 );
309 },
310 [scope],
311 );
312
313 return useVcsManagerAction({
314 operation: "run_change_request",
315 scope,
316 unavailableMessage: "Git action is unavailable.",
317 action,
318 });
319}
320
321export function useVcsPullAction(scope: SourceControlActionScope) {
322 const action = useCallback(async (): Promise<VcsPullResult | null> => {
323 if (!scope.cwd || !scope.environmentId) throw new Error("Git pull is unavailable.");
324 return vcsActionManager.pull(scope);
325 }, [scope]);
326
327 return useVcsManagerAction({
328 operation: "pull",
329 scope,
330 unavailableMessage: "Git pull is unavailable.",
331 action,
332 });
333}
334
335export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) {
336 const action = useCallback(
337 async (args: {
338 provider: "github" | "gitlab" | "bitbucket" | "azure-devops";
339 repository: string;
340 visibility: SourceControlRepositoryVisibility;
341 remoteName: string;
342 protocol: SourceControlCloneProtocol;
343 }): Promise<SourceControlPublishRepositoryResult> => {
344 if (!scope.cwd || !scope.environmentId) {
345 throw new Error("Repository publishing is unavailable.");
346 }
347 return ensureEnvironmentApi(scope.environmentId).sourceControl.publishRepository({
348 cwd: scope.cwd,
349 ...args,
350 });
351 },
352 [scope],
353 );
354
355 return useSourceControlAction({
356 kind: "publishRepository",
357 scope,
358 action,
359 });
360}
361
362export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) {
363 const action = useCallback(
364 async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => {
365 if (!scope.cwd || !scope.environmentId) {
366 throw new Error("Pull request thread preparation is unavailable.");
367 }
368 return ensureEnvironmentApi(scope.environmentId).git.preparePullRequestThread({
369 cwd: scope.cwd,
370 reference: args.reference,
371 mode: args.mode,
372 ...(args.threadId ? { threadId: args.threadId } : {}),
373 });
374 },
375 [scope],
376 );
377
378 return useSourceControlAction({
379 kind: "preparePullRequestThread",
380 scope,
381 action,
382 });
383}
384
385interface PullRequestResolutionTarget {
386 readonly environmentId: EnvironmentId | null;
387 readonly cwd: string | null;
388 readonly reference: string | null;
389}
390
391interface PullRequestResolutionState {
392 readonly data: GitResolvePullRequestResult | null;
393 readonly error: unknown;
394 readonly isPending: boolean;
395 readonly isFetching: boolean;
396}
397
398const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = {
399 data: null,
400 error: null,
401 isPending: false,
402 isFetching: false,
403};
404
405const pullRequestResolutionCache = new Map<string, GitResolvePullRequestResult>();
406
407function pullRequestResolutionKey(target: PullRequestResolutionTarget): string | null {
408 if (!target.environmentId || !target.cwd || !target.reference) {
409 return null;
410 }
411 return `${target.environmentId}:${target.cwd}:${target.reference}`;
412}
413
414export function readCachedPullRequestResolution(
415 target: PullRequestResolutionTarget,
416): GitResolvePullRequestResult | null {
417 const key = pullRequestResolutionKey(target);
418 return key ? (pullRequestResolutionCache.get(key) ?? null) : null;
419}
420
421export function usePullRequestResolution(
422 target: PullRequestResolutionTarget,
423): PullRequestResolutionState {
424 const stableTarget = useMemo(
425 () => ({
426 environmentId: target.environmentId,
427 cwd: target.cwd,
428 reference: target.reference,
429 }),
430 [target.cwd, target.environmentId, target.reference],
431 );
432 const key = pullRequestResolutionKey(stableTarget);
433 const [state, setState] = useState<PullRequestResolutionState>(() => {
434 const cached = readCachedPullRequestResolution(stableTarget);
435 return cached
436 ? { data: cached, error: null, isPending: false, isFetching: false }
437 : EMPTY_PULL_REQUEST_RESOLUTION;
438 });
439
440 useEffect(() => {
441 if (!key || !stableTarget.environmentId || !stableTarget.cwd || !stableTarget.reference) {
442 setState(EMPTY_PULL_REQUEST_RESOLUTION);
443 return;
444 }
445
446 const cached = pullRequestResolutionCache.get(key) ?? null;
447 setState({
448 data: cached,
449 error: null,
450 isPending: cached === null,
451 isFetching: true,
452 });
453
454 let cancelled = false;
455 ensureEnvironmentApi(stableTarget.environmentId)
456 .git.resolvePullRequest({ cwd: stableTarget.cwd, reference: stableTarget.reference })
457 .then((result) => {
458 if (cancelled) {
459 return;
460 }
461 pullRequestResolutionCache.set(key, result);
462 setState({ data: result, error: null, isPending: false, isFetching: false });
463 })
464 .catch((error: unknown) => {
465 if (cancelled) {
466 return;
467 }
468 setState({ data: cached, error, isPending: false, isFetching: false });
469 });
470
471 return () => {
472 cancelled = true;
473 };
474 }, [key, stableTarget]);
475
476 return state;
477}
apps/web/src/lib/sourceControlDiscoveryState.ts+10-18
...@@ -7,11 +7,13 @@ import {...@@ -7,11 +7,13 @@ import {
7 sourceControlDiscoveryStateAtom,7 sourceControlDiscoveryStateAtom,
8} from "@t3tools/client-runtime";8} from "@t3tools/client-runtime";
9import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts";9import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts";
10import * as Effect from "effect/Effect";10import { useEffect } from "react";
11import { Atom } from "effect/unstable/reactivity";
1211
13import { readPrimaryEnvironmentDescriptor } from "../environments/primary";12import { readPrimaryEnvironmentDescriptor } from "../environments/primary";
14import { readEnvironmentConnection } from "../environments/runtime";13import {
14 readEnvironmentConnection,
15 subscribeEnvironmentConnections,
16} from "../environments/runtime";
15import { readLocalApi } from "../localApi";17import { readLocalApi } from "../localApi";
16import { appAtomRegistry } from "../rpc/atomRegistry";18import { appAtomRegistry } from "../rpc/atomRegistry";
1719
...@@ -35,7 +37,7 @@ function sourceControlDiscoveryTarget(...@@ -35,7 +37,7 @@ function sourceControlDiscoveryTarget(
35 : { key: environmentId };37 : { key: environmentId };
36}38}
3739
38export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({40const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({
39 getRegistry: () => appAtomRegistry,41 getRegistry: () => appAtomRegistry,
40 getClient: (key) => {42 getClient: (key) => {
41 if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) {43 if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) {
...@@ -59,21 +61,11 @@ export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager...@@ -59,21 +61,11 @@ export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager
59 }61 }
60 return null;62 return null;
61 },63 },
64 subscribeClientChanges: subscribeEnvironmentConnections,
65 staleTimeMs: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS,
66 idleTtlMs: SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS,
62});67});
6368
64const sourceControlDiscoveryAutoRefreshAtom = Atom.family((targetKey: string) =>
65 Atom.make(() =>
66 Effect.promise(() => sourceControlDiscoveryManager.refresh({ key: targetKey })),
67 ).pipe(
68 Atom.swr({
69 staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS,
70 revalidateOnMount: true,
71 }),
72 Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS),
73 Atom.withLabel(`source-control-discovery:auto-refresh:${targetKey}`),
74 ),
75);
76
77export function refreshSourceControlDiscovery(69export function refreshSourceControlDiscovery(
78 input?: SourceControlDiscoveryTargetInput,70 input?: SourceControlDiscoveryTargetInput,
79): Promise<SourceControlDiscoveryResult | null> {71): Promise<SourceControlDiscoveryResult | null> {
...@@ -97,7 +89,7 @@ export function useSourceControlDiscovery(...@@ -97,7 +89,7 @@ export function useSourceControlDiscovery(
97 getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ??89 getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ??
98 SOURCE_CONTROL_DISCOVERY_TARGET.key;90 SOURCE_CONTROL_DISCOVERY_TARGET.key;
9991
100 useAtomValue(sourceControlDiscoveryAutoRefreshAtom(targetKey));92 useEffect(() => sourceControlDiscoveryManager.watch({ key: targetKey }), [targetKey]);
10193
102 return useAtomValue(sourceControlDiscoveryStateAtom(targetKey));94 return useAtomValue(sourceControlDiscoveryStateAtom(targetKey));
103}95}
apps/web/src/lib/terminalFocus.test.ts+18-1
...@@ -11,7 +11,13 @@ class MockHTMLElement {...@@ -11,7 +11,13 @@ class MockHTMLElement {
11 };11 };
1212
13 closest(selector: string): MockHTMLElement | null {13 closest(selector: string): MockHTMLElement | null {
14 return selector === ".thread-terminal-drawer .xterm" && this.isConnected ? this : null;14 if (!this.isConnected) {
15 return null;
16 }
17 if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") {
18 return this;
19 }
20 return null;
15 }21 }
16}22}
1723
...@@ -53,4 +59,15 @@ describe("isTerminalFocused", () => {...@@ -53,4 +59,15 @@ describe("isTerminalFocused", () => {
5359
54 expect(isTerminalFocused()).toBe(true);60 expect(isTerminalFocused()).toBe(true);
55 });61 });
62
63 it("returns true for focus inside the terminal drawer (e.g. sidebar)", () => {
64 const sidebarButton = new MockHTMLElement();
65 sidebarButton.className = "terminal-sidebar-button";
66 sidebarButton.isConnected = true;
67
68 globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
69 globalThis.document = { activeElement: sidebarButton } as Document;
70
71 expect(isTerminalFocused()).toBe(true);
72 });
56});73});
apps/web/src/lib/terminalFocus.ts+3-1
...@@ -3,5 +3,7 @@ export function isTerminalFocused(): boolean {...@@ -3,5 +3,7 @@ export function isTerminalFocused(): boolean {
3 if (!(activeElement instanceof HTMLElement)) return false;3 if (!(activeElement instanceof HTMLElement)) return false;
4 if (!activeElement.isConnected) return false;4 if (!activeElement.isConnected) return false;
5 if (activeElement.classList.contains("xterm-helper-textarea")) return true;5 if (activeElement.classList.contains("xterm-helper-textarea")) return true;
6 return activeElement.closest(".thread-terminal-drawer .xterm") !== null;6 if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true;
7 // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D).
8 return activeElement.closest(".thread-terminal-drawer") !== null;
7}9}
apps/web/src/lib/terminalStateCleanup.test.ts deleted-65
...@@ -1,65 +0,0 @@
1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
2import { ThreadId } from "@t3tools/contracts";
3import { describe, expect, it } from "vitest";
4
5import { collectActiveTerminalThreadIds } from "./terminalStateCleanup";
6
7const threadId = (id: string): ThreadId => ThreadId.make(id);
8const threadKey = (environmentId: string, id: string): string =>
9 scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id)));
10
11describe("collectActiveTerminalThreadIds", () => {
12 it("retains non-deleted server threads", () => {
13 const activeThreadIds = collectActiveTerminalThreadIds({
14 snapshotThreads: [
15 { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null },
16 { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null },
17 ],
18 draftThreadKeys: [],
19 });
20
21 expect(activeThreadIds).toEqual(
22 new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]),
23 );
24 });
25
26 it("ignores deleted and archived server threads and keeps local draft threads", () => {
27 const activeThreadIds = collectActiveTerminalThreadIds({
28 snapshotThreads: [
29 { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null },
30 {
31 key: threadKey("env-a", "server-deleted"),
32 deletedAt: "2026-03-05T08:00:00.000Z",
33 archivedAt: null,
34 },
35 {
36 key: threadKey("env-a", "server-archived"),
37 deletedAt: null,
38 archivedAt: "2026-03-05T09:00:00.000Z",
39 },
40 ],
41 draftThreadKeys: [threadKey("env-a", "local-draft")],
42 });
43
44 expect(activeThreadIds).toEqual(
45 new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]),
46 );
47 });
48
49 it("does not keep draft-linked terminal state for archived server threads", () => {
50 const archivedThreadId = threadKey("env-a", "server-archived");
51
52 const activeThreadIds = collectActiveTerminalThreadIds({
53 snapshotThreads: [
54 {
55 key: archivedThreadId,
56 deletedAt: null,
57 archivedAt: "2026-03-05T09:00:00.000Z",
58 },
59 ],
60 draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")],
61 });
62
63 expect(activeThreadIds).toEqual(new Set([threadKey("env-a", "local-draft")]));
64 });
65});
apps/web/src/lib/terminalStateCleanup.ts deleted-33
...@@ -1,33 +0,0 @@
1interface TerminalRetentionThread {
2 key: string;
3 deletedAt: string | null;
4 archivedAt: string | null;
5}
6
7interface CollectActiveTerminalThreadIdsInput {
8 snapshotThreads: readonly TerminalRetentionThread[];
9 draftThreadKeys: Iterable<string>;
10}
11
12export function collectActiveTerminalThreadIds(
13 input: CollectActiveTerminalThreadIdsInput,
14): Set<string> {
15 const activeThreadIds = new Set<string>();
16 const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread]));
17 for (const thread of input.snapshotThreads) {
18 if (thread.deletedAt !== null) continue;
19 if (thread.archivedAt !== null) continue;
20 activeThreadIds.add(thread.key);
21 }
22 for (const draftThreadKey of input.draftThreadKeys) {
23 const snapshotThread = snapshotThreadById.get(draftThreadKey);
24 if (
25 snapshotThread &&
26 (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null)
27 ) {
28 continue;
29 }
30 activeThreadIds.add(draftThreadKey);
31 }
32 return activeThreadIds;
33}
apps/web/src/lib/terminalUiStateCleanup.test.ts created+65
...@@ -0,0 +1,65 @@
1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
2import { ThreadId } from "@t3tools/contracts";
3import { describe, expect, it } from "vitest";
4
5import { collectActiveTerminalUiThreadKeys } from "./terminalUiStateCleanup";
6
7const threadId = (id: string): ThreadId => ThreadId.make(id);
8const threadKey = (environmentId: string, id: string): string =>
9 scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id)));
10
11describe("collectActiveTerminalUiThreadKeys", () => {
12 it("retains non-deleted server threads", () => {
13 const activeThreadKeys = collectActiveTerminalUiThreadKeys({
14 snapshotThreads: [
15 { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null },
16 { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null },
17 ],
18 draftThreadKeys: [],
19 });
20
21 expect(activeThreadKeys).toEqual(
22 new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]),
23 );
24 });
25
26 it("ignores deleted and archived server threads and keeps local draft threads", () => {
27 const activeThreadKeys = collectActiveTerminalUiThreadKeys({
28 snapshotThreads: [
29 { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null },
30 {
31 key: threadKey("env-a", "server-deleted"),
32 deletedAt: "2026-03-05T08:00:00.000Z",
33 archivedAt: null,
34 },
35 {
36 key: threadKey("env-a", "server-archived"),
37 deletedAt: null,
38 archivedAt: "2026-03-05T09:00:00.000Z",
39 },
40 ],
41 draftThreadKeys: [threadKey("env-a", "local-draft")],
42 });
43
44 expect(activeThreadKeys).toEqual(
45 new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]),
46 );
47 });
48
49 it("does not keep draft-linked terminal UI state for archived server threads", () => {
50 const archivedThreadId = threadKey("env-a", "server-archived");
51
52 const activeThreadKeys = collectActiveTerminalUiThreadKeys({
53 snapshotThreads: [
54 {
55 key: archivedThreadId,
56 deletedAt: null,
57 archivedAt: "2026-03-05T09:00:00.000Z",
58 },
59 ],
60 draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")],
61 });
62
63 expect(activeThreadKeys).toEqual(new Set([threadKey("env-a", "local-draft")]));
64 });
65});
apps/web/src/lib/terminalUiStateCleanup.ts created+33
...@@ -0,0 +1,33 @@
1interface TerminalUiRetentionThread {
2 key: string;
3 deletedAt: string | null;
4 archivedAt: string | null;
5}
6
7interface CollectActiveTerminalUiThreadKeysInput {
8 snapshotThreads: readonly TerminalUiRetentionThread[];
9 draftThreadKeys: Iterable<string>;
10}
11
12export function collectActiveTerminalUiThreadKeys(
13 input: CollectActiveTerminalUiThreadKeysInput,
14): Set<string> {
15 const activeThreadKeys = new Set<string>();
16 const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread]));
17 for (const thread of input.snapshotThreads) {
18 if (thread.deletedAt !== null) continue;
19 if (thread.archivedAt !== null) continue;
20 activeThreadKeys.add(thread.key);
21 }
22 for (const draftThreadKey of input.draftThreadKeys) {
23 const snapshotThread = snapshotThreadById.get(draftThreadKey);
24 if (
25 snapshotThread &&
26 (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null)
27 ) {
28 continue;
29 }
30 activeThreadKeys.add(draftThreadKey);
31 }
32 return activeThreadKeys;
33}
apps/web/src/lib/vcsRefState.ts created+48
...@@ -0,0 +1,48 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type VcsRefState,
4 type VcsRefTarget,
5 EMPTY_VCS_REF_ATOM,
6 EMPTY_VCS_REF_STATE,
7 createVcsRefManager,
8 getVcsRefTargetKey,
9 vcsRefStateAtom,
10} from "@t3tools/client-runtime";
11import { useEffect, useMemo } from "react";
12
13import {
14 readEnvironmentConnection,
15 subscribeEnvironmentConnections,
16} from "../environments/runtime";
17import { appAtomRegistry } from "../rpc/atomRegistry";
18
19const VCS_REF_LIST_LIMIT = 100;
20const VCS_REF_STALE_TIME_MS = 5_000;
21
22export const vcsRefManager = createVcsRefManager({
23 getRegistry: () => appAtomRegistry,
24 getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.vcs ?? null,
25 subscribeClientChanges: subscribeEnvironmentConnections,
26 watchLimit: VCS_REF_LIST_LIMIT,
27 staleTimeMs: VCS_REF_STALE_TIME_MS,
28 onBackgroundError: (error) => {
29 console.warn("[vcs-refs] background refresh failed", error);
30 },
31});
32
33export function useVcsRefs(target: VcsRefTarget): VcsRefState {
34 const stableTarget = useMemo(
35 () => ({
36 environmentId: target.environmentId,
37 cwd: target.cwd,
38 query: target.query ?? null,
39 }),
40 [target.cwd, target.environmentId, target.query],
41 );
42 const targetKey = getVcsRefTargetKey(stableTarget);
43
44 useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]);
45
46 const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM);
47 return targetKey === null ? EMPTY_VCS_REF_STATE : state;
48}
apps/web/src/lib/vcsStatusState.ts created+63
...@@ -0,0 +1,63 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type VcsStatusClient,
4 type VcsStatusState,
5 type VcsStatusTarget,
6 EMPTY_VCS_STATUS_ATOM,
7 EMPTY_VCS_STATUS_STATE,
8 createVcsStatusManager,
9 getVcsStatusTargetKey,
10 vcsStatusStateAtom,
11} from "@t3tools/client-runtime";
12import type { EnvironmentId } from "@t3tools/contracts";
13import { useEffect } from "react";
14
15import {
16 readEnvironmentConnection,
17 subscribeEnvironmentConnections,
18} from "../environments/runtime";
19import { appAtomRegistry } from "../rpc/atomRegistry";
20
21export type { VcsStatusState, VcsStatusTarget };
22
23const manager = createVcsStatusManager({
24 getRegistry: () => appAtomRegistry,
25 getClient: (environmentId) => {
26 const connection = readEnvironmentConnection(environmentId as EnvironmentId);
27 return connection ? connection.client.vcs : null;
28 },
29 getClientIdentity: (environmentId) => {
30 const connection = readEnvironmentConnection(environmentId as EnvironmentId);
31 return connection ? connection.environmentId : null;
32 },
33 subscribeClientChanges: subscribeEnvironmentConnections,
34});
35
36export function getVcsStatusSnapshot(target: VcsStatusTarget): VcsStatusState {
37 return manager.getSnapshot(target);
38}
39
40export function watchVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient): () => void {
41 return manager.watch(target, client);
42}
43
44export function refreshVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient) {
45 return manager.refresh(target, client);
46}
47
48export function resetVcsStatusStateForTests(): void {
49 manager.reset();
50}
51
52export function useVcsStatus(target: VcsStatusTarget): VcsStatusState {
53 const targetKey = getVcsStatusTargetKey(target);
54 useEffect(
55 () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }),
56 [target.environmentId, target.cwd],
57 );
58
59 const state = useAtomValue(
60 targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM,
61 );
62 return targetKey === null ? EMPTY_VCS_STATUS_STATE : state;
63}
apps/web/src/localApi.test.ts+43-13
...@@ -10,7 +10,8 @@ import {...@@ -10,7 +10,8 @@ import {
10 ProviderInstanceId,10 ProviderInstanceId,
11 type ServerConfig,11 type ServerConfig,
12 type ServerProvider,12 type ServerProvider,
13 type TerminalEvent,13 type TerminalAttachStreamEvent,
14 type TerminalMetadataStreamEvent,
14 ThreadId,15 ThreadId,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";17import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
...@@ -32,7 +33,8 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even...@@ -32,7 +33,8 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even
32 };33 };
33}34}
3435
35const terminalEventListeners = new Set<(event: TerminalEvent) => void>();36const terminalAttachListeners = new Set<(event: TerminalAttachStreamEvent) => void>();
37const terminalMetadataListeners = new Set<(event: TerminalMetadataStreamEvent) => void>();
36const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>();38const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>();
37const gitStatusListeners = new Set<(event: VcsStatusResult) => void>();39const gitStatusListeners = new Set<(event: VcsStatusResult) => void>();
3840
...@@ -40,13 +42,16 @@ const rpcClientMock = {...@@ -40,13 +42,16 @@ const rpcClientMock = {
40 dispose: vi.fn(),42 dispose: vi.fn(),
41 terminal: {43 terminal: {
42 open: vi.fn(),44 open: vi.fn(),
45 attach: vi.fn((_input: unknown, listener: (event: TerminalAttachStreamEvent) => void) =>
46 registerListener(terminalAttachListeners, listener),
47 ),
43 write: vi.fn(),48 write: vi.fn(),
44 resize: vi.fn(),49 resize: vi.fn(),
45 clear: vi.fn(),50 clear: vi.fn(),
46 restart: vi.fn(),51 restart: vi.fn(),
47 close: vi.fn(),52 close: vi.fn(),
48 onEvent: vi.fn((listener: (event: TerminalEvent) => void) =>53 onMetadata: vi.fn((listener: (event: TerminalMetadataStreamEvent) => void) =>
49 registerListener(terminalEventListeners, listener),54 registerListener(terminalMetadataListeners, listener),
50 ),55 ),
51 },56 },
52 projects: {57 projects: {
...@@ -82,6 +87,9 @@ const rpcClientMock = {...@@ -82,6 +87,9 @@ const rpcClientMock = {
82 resolvePullRequest: vi.fn(),87 resolvePullRequest: vi.fn(),
83 preparePullRequestThread: vi.fn(),88 preparePullRequestThread: vi.fn(),
84 },89 },
90 review: {
91 getDiffPreview: vi.fn(),
92 },
85 server: {93 server: {
86 getConfig: vi.fn(),94 getConfig: vi.fn(),
87 refreshProviders: vi.fn(),95 refreshProviders: vi.fn(),
...@@ -126,6 +134,7 @@ vi.mock("./environments/runtime", () => ({...@@ -126,6 +134,7 @@ vi.mock("./environments/runtime", () => ({
126 resetEnvironmentServiceForTests: vi.fn(),134 resetEnvironmentServiceForTests: vi.fn(),
127 resetSavedEnvironmentRegistryStoreForTests: vi.fn(),135 resetSavedEnvironmentRegistryStoreForTests: vi.fn(),
128 resetSavedEnvironmentRuntimeStoreForTests: vi.fn(),136 resetSavedEnvironmentRuntimeStoreForTests: vi.fn(),
137 subscribeEnvironmentConnections: vi.fn(() => () => undefined),
129}));138}));
130139
131vi.mock("./contextMenuFallback", () => ({140vi.mock("./contextMenuFallback", () => ({
...@@ -315,7 +324,8 @@ beforeEach(() => {...@@ -315,7 +324,8 @@ beforeEach(() => {
315 vi.resetModules();324 vi.resetModules();
316 vi.clearAllMocks();325 vi.clearAllMocks();
317 showContextMenuFallbackMock.mockReset();326 showContextMenuFallbackMock.mockReset();
318 terminalEventListeners.clear();327 terminalAttachListeners.clear();
328 terminalMetadataListeners.clear();
319 shellStreamListeners.clear();329 shellStreamListeners.clear();
320 gitStatusListeners.clear();330 gitStatusListeners.clear();
321 const testWindow = getWindowForTest();331 const testWindow = getWindowForTest();
...@@ -343,24 +353,43 @@ describe("wsApi", () => {...@@ -343,24 +353,43 @@ describe("wsApi", () => {
343 expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled();353 expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled();
344 });354 });
345355
346 it("forwards terminal and shell stream events", async () => {356 it("forwards terminal attach, metadata, and shell stream events", async () => {
347 const { createEnvironmentApi } = await import("./environmentApi");357 const { createEnvironmentApi } = await import("./environmentApi");
348358
349 const api = createEnvironmentApi(rpcClientMock as never);359 const api = createEnvironmentApi(rpcClientMock as never);
350 const onTerminalEvent = vi.fn();360 const onTerminalAttachEvent = vi.fn();
361 const onTerminalMetadataEvent = vi.fn();
351 const onShellEvent = vi.fn();362 const onShellEvent = vi.fn();
352363
353 api.terminal.onEvent(onTerminalEvent);364 api.terminal.attach({ threadId: "thread-1", terminalId: "terminal-1" }, onTerminalAttachEvent);
365 api.terminal.onMetadata(onTerminalMetadataEvent);
354 api.orchestration.subscribeShell(onShellEvent);366 api.orchestration.subscribeShell(onShellEvent);
355367
356 const terminalEvent = {368 const terminalAttachEvent = {
357 threadId: "thread-1",369 threadId: "thread-1",
358 terminalId: "terminal-1",370 terminalId: "terminal-1",
359 createdAt: "2026-02-24T00:00:00.000Z",
360 type: "output",371 type: "output",
361 data: "hello",372 data: "hello",
362 } as const;373 } satisfies TerminalAttachStreamEvent;
363 emitEvent(terminalEventListeners, terminalEvent);374 emitEvent(terminalAttachListeners, terminalAttachEvent);
375
376 const terminalMetadataEvent = {
377 type: "upsert",
378 terminal: {
379 threadId: "thread-1",
380 terminalId: "terminal-1",
381 cwd: "/tmp/workspace",
382 worktreePath: null,
383 status: "running",
384 pid: 123,
385 exitCode: null,
386 exitSignal: null,
387 hasRunningSubprocess: true,
388 label: "terminal-1",
389 updatedAt: "2026-02-24T00:00:00.000Z",
390 },
391 } satisfies TerminalMetadataStreamEvent;
392 emitEvent(terminalMetadataListeners, terminalMetadataEvent);
364393
365 const shellEvent = {394 const shellEvent = {
366 kind: "project-upserted" as const,395 kind: "project-upserted" as const,
...@@ -380,7 +409,8 @@ describe("wsApi", () => {...@@ -380,7 +409,8 @@ describe("wsApi", () => {
380 } satisfies OrchestrationShellStreamItem;409 } satisfies OrchestrationShellStreamItem;
381 emitEvent(shellStreamListeners, shellEvent);410 emitEvent(shellStreamListeners, shellEvent);
382411
383 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);412 expect(onTerminalAttachEvent).toHaveBeenCalledWith(terminalAttachEvent);
413 expect(onTerminalMetadataEvent).toHaveBeenCalledWith(terminalMetadataEvent);
384 expect(onShellEvent).toHaveBeenCalledWith(shellEvent);414 expect(onShellEvent).toHaveBeenCalledWith(shellEvent);
385 });415 });
386416
apps/web/src/localApi.ts+3-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";1import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
2import type { WsRpcClient } from "@t3tools/client-runtime";
23
3import { resetGitStatusStateForTests } from "./lib/gitStatusState";4import { resetVcsStatusStateForTests } from "./lib/vcsStatusState";
4import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState";5import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState";
5import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
6import { resetServerStateForTests } from "./rpc/serverState";7import { resetServerStateForTests } from "./rpc/serverState";
...@@ -14,7 +15,6 @@ import {...@@ -14,7 +15,6 @@ import {
14 resetEnvironmentServiceForTests,15 resetEnvironmentServiceForTests,
15} from "./environments/runtime";16} from "./environments/runtime";
16import { getPrimaryKnownEnvironment } from "./environments/primary";17import { getPrimaryKnownEnvironment } from "./environments/primary";
17import { type WsRpcClient } from "./rpc/wsRpcClient";
18import { showContextMenuFallback } from "./contextMenuFallback";18import { showContextMenuFallback } from "./contextMenuFallback";
19import {19import {
20 readBrowserClientSettings,20 readBrowserClientSettings,
...@@ -200,7 +200,7 @@ export async function __resetLocalApiForTests() {...@@ -200,7 +200,7 @@ export async function __resetLocalApiForTests() {
200 const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings");200 const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings");
201 __resetClientSettingsPersistenceForTests();201 __resetClientSettingsPersistenceForTests();
202 await resetEnvironmentServiceForTests();202 await resetEnvironmentServiceForTests();
203 resetGitStatusStateForTests();203 resetVcsStatusStateForTests();
204 resetSourceControlDiscoveryStateForTests();204 resetSourceControlDiscoveryStateForTests();
205 resetRequestLatencyStateForTests();205 resetRequestLatencyStateForTests();
206 resetSavedEnvironmentRegistryStoreForTests();206 resetSavedEnvironmentRegistryStoreForTests();
apps/web/src/orchestrationEventEffects.test.ts+3-3
...@@ -71,7 +71,7 @@ describe("deriveOrchestrationBatchEffects", () => {...@@ -71,7 +71,7 @@ describe("deriveOrchestrationBatchEffects", () => {
7171
72 expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]);72 expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]);
73 expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]);73 expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]);
74 expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]);74 expect(effects.removeTerminalUiStateThreadIds).toEqual([deletedThreadId, archivedThreadId]);
75 expect(effects.needsProviderInvalidation).toBe(false);75 expect(effects.needsProviderInvalidation).toBe(false);
76 });76 });
7777
...@@ -109,7 +109,7 @@ describe("deriveOrchestrationBatchEffects", () => {...@@ -109,7 +109,7 @@ describe("deriveOrchestrationBatchEffects", () => {
109109
110 expect(effects.promoteDraftThreadIds).toEqual([threadId]);110 expect(effects.promoteDraftThreadIds).toEqual([threadId]);
111 expect(effects.clearDeletedThreadIds).toEqual([]);111 expect(effects.clearDeletedThreadIds).toEqual([]);
112 expect(effects.removeTerminalStateThreadIds).toEqual([]);112 expect(effects.removeTerminalUiStateThreadIds).toEqual([]);
113 expect(effects.needsProviderInvalidation).toBe(true);113 expect(effects.needsProviderInvalidation).toBe(true);
114 });114 });
115115
...@@ -130,6 +130,6 @@ describe("deriveOrchestrationBatchEffects", () => {...@@ -130,6 +130,6 @@ describe("deriveOrchestrationBatchEffects", () => {
130130
131 expect(effects.promoteDraftThreadIds).toEqual([]);131 expect(effects.promoteDraftThreadIds).toEqual([]);
132 expect(effects.clearDeletedThreadIds).toEqual([]);132 expect(effects.clearDeletedThreadIds).toEqual([]);
133 expect(effects.removeTerminalStateThreadIds).toEqual([]);133 expect(effects.removeTerminalUiStateThreadIds).toEqual([]);
134 });134 });
135});135});
apps/web/src/orchestrationEventEffects.ts+10-10
...@@ -3,7 +3,7 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";...@@ -3,7 +3,7 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
3export interface OrchestrationBatchEffects {3export interface OrchestrationBatchEffects {
4 promoteDraftThreadIds: ThreadId[];4 promoteDraftThreadIds: ThreadId[];
5 clearDeletedThreadIds: ThreadId[];5 clearDeletedThreadIds: ThreadId[];
6 removeTerminalStateThreadIds: ThreadId[];6 removeTerminalUiStateThreadIds: ThreadId[];
7 needsProviderInvalidation: boolean;7 needsProviderInvalidation: boolean;
8}8}
99
...@@ -15,7 +15,7 @@ export function deriveOrchestrationBatchEffects(...@@ -15,7 +15,7 @@ export function deriveOrchestrationBatchEffects(
15 {15 {
16 clearPromotedDraft: boolean;16 clearPromotedDraft: boolean;
17 clearDeletedThread: boolean;17 clearDeletedThread: boolean;
18 removeTerminalState: boolean;18 removeTerminalUiState: boolean;
19 }19 }
20 >();20 >();
21 let needsProviderInvalidation = false;21 let needsProviderInvalidation = false;
...@@ -32,7 +32,7 @@ export function deriveOrchestrationBatchEffects(...@@ -32,7 +32,7 @@ export function deriveOrchestrationBatchEffects(
32 threadLifecycleEffects.set(event.payload.threadId, {32 threadLifecycleEffects.set(event.payload.threadId, {
33 clearPromotedDraft: true,33 clearPromotedDraft: true,
34 clearDeletedThread: false,34 clearDeletedThread: false,
35 removeTerminalState: false,35 removeTerminalUiState: false,
36 });36 });
37 break;37 break;
38 }38 }
...@@ -41,7 +41,7 @@ export function deriveOrchestrationBatchEffects(...@@ -41,7 +41,7 @@ export function deriveOrchestrationBatchEffects(
41 threadLifecycleEffects.set(event.payload.threadId, {41 threadLifecycleEffects.set(event.payload.threadId, {
42 clearPromotedDraft: false,42 clearPromotedDraft: false,
43 clearDeletedThread: true,43 clearDeletedThread: true,
44 removeTerminalState: true,44 removeTerminalUiState: true,
45 });45 });
46 break;46 break;
47 }47 }
...@@ -50,7 +50,7 @@ export function deriveOrchestrationBatchEffects(...@@ -50,7 +50,7 @@ export function deriveOrchestrationBatchEffects(
50 threadLifecycleEffects.set(event.payload.threadId, {50 threadLifecycleEffects.set(event.payload.threadId, {
51 clearPromotedDraft: false,51 clearPromotedDraft: false,
52 clearDeletedThread: false,52 clearDeletedThread: false,
53 removeTerminalState: true,53 removeTerminalUiState: true,
54 });54 });
55 break;55 break;
56 }56 }
...@@ -59,7 +59,7 @@ export function deriveOrchestrationBatchEffects(...@@ -59,7 +59,7 @@ export function deriveOrchestrationBatchEffects(
59 threadLifecycleEffects.set(event.payload.threadId, {59 threadLifecycleEffects.set(event.payload.threadId, {
60 clearPromotedDraft: false,60 clearPromotedDraft: false,
61 clearDeletedThread: false,61 clearDeletedThread: false,
62 removeTerminalState: false,62 removeTerminalUiState: false,
63 });63 });
64 break;64 break;
65 }65 }
...@@ -72,7 +72,7 @@ export function deriveOrchestrationBatchEffects(...@@ -72,7 +72,7 @@ export function deriveOrchestrationBatchEffects(
7272
73 const promoteDraftThreadIds: ThreadId[] = [];73 const promoteDraftThreadIds: ThreadId[] = [];
74 const clearDeletedThreadIds: ThreadId[] = [];74 const clearDeletedThreadIds: ThreadId[] = [];
75 const removeTerminalStateThreadIds: ThreadId[] = [];75 const removeTerminalUiStateThreadIds: ThreadId[] = [];
76 for (const [threadId, effect] of threadLifecycleEffects) {76 for (const [threadId, effect] of threadLifecycleEffects) {
77 if (effect.clearPromotedDraft) {77 if (effect.clearPromotedDraft) {
78 promoteDraftThreadIds.push(threadId);78 promoteDraftThreadIds.push(threadId);
...@@ -80,15 +80,15 @@ export function deriveOrchestrationBatchEffects(...@@ -80,15 +80,15 @@ export function deriveOrchestrationBatchEffects(
80 if (effect.clearDeletedThread) {80 if (effect.clearDeletedThread) {
81 clearDeletedThreadIds.push(threadId);81 clearDeletedThreadIds.push(threadId);
82 }82 }
83 if (effect.removeTerminalState) {83 if (effect.removeTerminalUiState) {
84 removeTerminalStateThreadIds.push(threadId);84 removeTerminalUiStateThreadIds.push(threadId);
85 }85 }
86 }86 }
8787
88 return {88 return {
89 promoteDraftThreadIds,89 promoteDraftThreadIds,
90 clearDeletedThreadIds,90 clearDeletedThreadIds,
91 removeTerminalStateThreadIds,91 removeTerminalUiStateThreadIds,
92 needsProviderInvalidation,92 needsProviderInvalidation,
93 };93 };
94}94}
apps/web/src/pairingUrl.ts+5-33
...@@ -1,33 +1,5 @@...@@ -1,33 +1,5 @@
1const PAIRING_TOKEN_PARAM = "token";1export {
22 getPairingTokenFromUrl,
3function readHashParams(url: URL): URLSearchParams {3 setPairingTokenOnUrl,
4 return new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);4 stripPairingTokenFromUrl,
5}5} from "@t3tools/shared/remote";
6
7export function getPairingTokenFromUrl(url: URL): string | null {
8 const hashToken = readHashParams(url).get(PAIRING_TOKEN_PARAM)?.trim() ?? "";
9 if (hashToken.length > 0) {
10 return hashToken;
11 }
12
13 const searchToken = url.searchParams.get(PAIRING_TOKEN_PARAM)?.trim() ?? "";
14 return searchToken.length > 0 ? searchToken : null;
15}
16
17export function stripPairingTokenFromUrl(url: URL): URL {
18 const next = new URL(url.toString());
19 const hashParams = readHashParams(next);
20 if (hashParams.has(PAIRING_TOKEN_PARAM)) {
21 hashParams.delete(PAIRING_TOKEN_PARAM);
22 next.hash = hashParams.toString();
23 }
24 next.searchParams.delete(PAIRING_TOKEN_PARAM);
25 return next;
26}
27
28export function setPairingTokenOnUrl(url: URL, credential: string): URL {
29 const next = new URL(url.toString());
30 next.searchParams.delete(PAIRING_TOKEN_PARAM);
31 next.hash = new URLSearchParams([[PAIRING_TOKEN_PARAM, credential]]).toString();
32 return next;
33}
apps/web/src/reviewCommentContext.test.ts created+77
...@@ -0,0 +1,77 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 buildReviewCommentRenderablePatch,
5 parseReviewCommentMessageSegments,
6} from "./reviewCommentContext";
7
8describe("review comment context parsing", () => {
9 it("extracts comment metadata, user text, and fenced diff without raw wrapper text", () => {
10 const segments = parseReviewCommentMessageSegments(
11 [
12 'Before <review_comment sectionId="turn:2" sectionTitle="Turn 2" filePath="apps/web/src/lib/contextWindow.test.ts" startIndex="3" endIndex="14" rangeLabel="+47 to +58">',
13 "Wadduo",
14 "```diff",
15 "@@ -0,0 +47,2 @@",
16 '+ it("keeps valid zero-usage snapshots", () => {',
17 "+ expect(snapshot).not.toBeNull();",
18 "```",
19 "</review_comment> after",
20 ].join("\n"),
21 );
22
23 expect(segments).toHaveLength(3);
24 expect(segments[0]).toEqual(
25 expect.objectContaining({
26 kind: "text",
27 text: expect.stringContaining("Before"),
28 }),
29 );
30 expect(segments[1]).toEqual(
31 expect.objectContaining({
32 kind: "review-comment",
33 comment: expect.objectContaining({
34 filePath: "apps/web/src/lib/contextWindow.test.ts",
35 rangeLabel: "+47 to +58",
36 text: "Wadduo",
37 diff: expect.stringContaining('it("keeps valid zero-usage snapshots"'),
38 }),
39 }),
40 );
41 expect(segments[2]).toEqual(
42 expect.objectContaining({
43 kind: "text",
44 text: " after",
45 }),
46 );
47 });
48
49 it("wraps hunk-only review diffs in a renderable file patch", () => {
50 const [segment] = parseReviewCommentMessageSegments(
51 [
52 '<review_comment sectionId="s" filePath="src/app.ts" startIndex="0" endIndex="0">',
53 "Please check this.",
54 "```diff",
55 "@@ -1,1 +1,1 @@",
56 "-old",
57 "+new",
58 "```",
59 "</review_comment>",
60 ].join("\n"),
61 );
62
63 expect(segment?.kind).toBe("review-comment");
64 if (segment?.kind !== "review-comment") return;
65
66 expect(buildReviewCommentRenderablePatch(segment.comment)).toBe(
67 [
68 "diff --git a/src/app.ts b/src/app.ts",
69 "--- a/src/app.ts",
70 "+++ b/src/app.ts",
71 "@@ -1,1 +1,1 @@",
72 "-old",
73 "+new",
74 ].join("\n"),
75 );
76 });
77});
apps/web/src/reviewCommentContext.ts created+154
...@@ -0,0 +1,154 @@
1export interface ReviewCommentContext {
2 readonly id: string;
3 readonly sectionId: string;
4 readonly sectionTitle: string;
5 readonly filePath: string;
6 readonly startIndex: number;
7 readonly endIndex: number;
8 readonly rangeLabel: string;
9 readonly text: string;
10 readonly diff: string;
11}
12
13export type ReviewCommentMessageSegment =
14 | {
15 readonly kind: "text";
16 readonly id: string;
17 readonly text: string;
18 }
19 | {
20 readonly kind: "review-comment";
21 readonly comment: ReviewCommentContext;
22 };
23
24const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;
25const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
26const REVIEW_COMMENT_DIFF_FENCE_PATTERN = /```diff\s*\n([\s\S]*?)\n```/;
27
28function unescapeReviewCommentAttribute(value: string): string {
29 return value
30 .replace(/&lt;/g, "<")
31 .replace(/&quot;/g, '"')
32 .replace(/&amp;/g, "&");
33}
34
35function readReviewCommentAttributes(rawAttributes: string): Record<string, string> {
36 const attributes: Record<string, string> = {};
37 for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) {
38 attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? "");
39 }
40 return attributes;
41}
42
43function readNonNegativeInteger(value: string | undefined): number | null {
44 if (value === undefined || !/^\d+$/.test(value)) {
45 return null;
46 }
47 return Number(value);
48}
49
50function extractReviewCommentText(rawBody: string): string {
51 const fenceIndex = rawBody.indexOf("```diff");
52 const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody;
53 return commentBody.trim();
54}
55
56function extractReviewCommentDiff(rawBody: string): string {
57 return rawBody.match(REVIEW_COMMENT_DIFF_FENCE_PATTERN)?.[1]?.trim() ?? "";
58}
59
60function parseReviewCommentContext(
61 rawAttributes: string,
62 rawBody: string,
63 index: number,
64): ReviewCommentContext | null {
65 const attributes = readReviewCommentAttributes(rawAttributes);
66 const startIndex = readNonNegativeInteger(attributes.startIndex);
67 const endIndex = readNonNegativeInteger(attributes.endIndex);
68 const filePath = attributes.filePath?.trim();
69 const sectionId = attributes.sectionId?.trim();
70 if (!filePath || !sectionId || startIndex === null || endIndex === null) {
71 return null;
72 }
73
74 return {
75 id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,
76 sectionId,
77 sectionTitle: attributes.sectionTitle?.trim() || "Review",
78 filePath,
79 startIndex: Math.min(startIndex, endIndex),
80 endIndex: Math.max(startIndex, endIndex),
81 rangeLabel: attributes.rangeLabel?.trim() || "line",
82 text: extractReviewCommentText(rawBody),
83 diff: extractReviewCommentDiff(rawBody),
84 };
85}
86
87export function parseReviewCommentMessageSegments(
88 value: string,
89): ReadonlyArray<ReviewCommentMessageSegment> {
90 const segments: ReviewCommentMessageSegment[] = [];
91 let cursor = 0;
92 let parsedCommentIndex = 0;
93
94 for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) {
95 const matchIndex = match.index ?? 0;
96 const beforeText = value.slice(cursor, matchIndex);
97 if (beforeText.length > 0) {
98 segments.push({
99 kind: "text",
100 id: `review-comment-text:${cursor}`,
101 text: beforeText,
102 });
103 }
104
105 const comment = parseReviewCommentContext(match[1] ?? "", match[2] ?? "", parsedCommentIndex);
106 if (comment) {
107 segments.push({ kind: "review-comment", comment });
108 parsedCommentIndex += 1;
109 } else {
110 segments.push({
111 kind: "text",
112 id: `review-comment-invalid:${matchIndex}`,
113 text: match[0],
114 });
115 }
116
117 cursor = matchIndex + match[0].length;
118 }
119
120 const rest = value.slice(cursor);
121 if (rest.length > 0) {
122 segments.push({
123 kind: "text",
124 id: `review-comment-text:${cursor}`,
125 text: rest,
126 });
127 }
128
129 return segments;
130}
131
132export function hasReviewCommentMessageSegments(value: string): boolean {
133 return parseReviewCommentMessageSegments(value).some(
134 (segment) => segment.kind === "review-comment",
135 );
136}
137
138export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext): string {
139 const diff = comment.diff.trim();
140 if (diff.length === 0) {
141 return "";
142 }
143 if (diff.startsWith("diff --git ")) {
144 return diff;
145 }
146
147 const normalizedPath = comment.filePath.replaceAll("\\", "/");
148 return [
149 `diff --git a/${normalizedPath} b/${normalizedPath}`,
150 `--- a/${normalizedPath}`,
151 `+++ b/${normalizedPath}`,
152 diff,
153 ].join("\n");
154}
apps/web/src/routes/_chat.tsx+3-3
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9} from "../lib/chatThreadActions";9} from "../lib/chatThreadActions";
10import { isTerminalFocused } from "../lib/terminalFocus";10import { isTerminalFocused } from "../lib/terminalFocus";
11import { resolveShortcutCommand } from "../keybindings";11import { resolveShortcutCommand } from "../keybindings";
12import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";12import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
13import { useThreadSelectionStore } from "../threadSelectionStore";13import { useThreadSelectionStore } from "../threadSelectionStore";
14import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";14import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
15import { useSettings } from "~/hooks/useSettings";15import { useSettings } from "~/hooks/useSettings";
...@@ -21,9 +21,9 @@ function ChatRouteGlobalShortcuts() {...@@ -21,9 +21,9 @@ function ChatRouteGlobalShortcuts() {
21 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =21 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
22 useHandleNewThread();22 useHandleNewThread();
23 const keybindings = useServerKeybindings();23 const keybindings = useServerKeybindings();
24 const terminalOpen = useTerminalStateStore((state) =>24 const terminalOpen = useTerminalUiStateStore((state) =>
25 routeThreadRef25 routeThreadRef
26 ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen26 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
27 : false,27 : false,
28 );28 );
29 const appSettings = useSettings();29 const appSettings = useSettings();
apps/web/src/rpc/protocol.ts deleted-328
...@@ -1,328 +0,0 @@
1import { WsRpcGroup } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schedule from "effect/Schedule";
6import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
7import * as Socket from "effect/unstable/socket/Socket";
8
9import {
10 acknowledgeRpcRequest,
11 clearAllTrackedRpcRequests,
12 trackRpcRequestSent,
13} from "./requestLatencyState";
14import {
15 getWsReconnectDelayMsForRetry,
16 recordWsConnectionAttempt,
17 recordWsConnectionClosed,
18 recordWsConnectionErrored,
19 recordWsConnectionOpened,
20 type WsConnectionMetadata,
21 WS_RECONNECT_MAX_RETRIES,
22} from "./wsConnectionState";
23
24export interface WsProtocolCloseContext {
25 readonly intentional: boolean;
26}
27
28export interface WsProtocolLifecycleHandlers {
29 readonly getConnectionLabel?: () => string | null;
30 readonly getVersionMismatchHint?: () => string | null;
31 readonly isCloseIntentional?: () => boolean;
32 readonly isActive?: () => boolean;
33 readonly onAttempt?: (socketUrl: string) => void;
34 readonly onOpen?: () => void;
35 readonly onHeartbeatPing?: () => void;
36 readonly onHeartbeatPong?: () => void;
37 readonly onHeartbeatTimeout?: () => void;
38 readonly onRequestStart?: (info: {
39 readonly id: string;
40 readonly tag: string;
41 readonly stream: boolean;
42 }) => void;
43 readonly onRequestChunk?: (info: {
44 readonly id: string;
45 readonly tag: string;
46 readonly chunkCount: number;
47 }) => void;
48 readonly onRequestExit?: (info: {
49 readonly id: string;
50 readonly tag: string;
51 readonly stream: boolean;
52 }) => void;
53 readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void;
54 readonly onError?: (message: string) => void;
55 readonly onClose?: (
56 details: { readonly code: number; readonly reason: string },
57 context: WsProtocolCloseContext,
58 ) => void;
59}
60
61export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
62type RpcClientFactory = typeof makeWsRpcProtocolClient;
63export type WsRpcProtocolClient =
64 RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never;
65export type WsRpcProtocolSocketUrlProvider = string | (() => Promise<string>);
66
67function formatSocketErrorMessage(error: unknown): string {
68 if (error instanceof Error && error.message.trim().length > 0) {
69 return error.message;
70 }
71 return String(error);
72}
73
74function resolveWsRpcSocketUrl(rawUrl: string): string {
75 const resolved = new URL(rawUrl);
76 if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") {
77 throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`);
78 }
79
80 resolved.pathname = "/ws";
81 return resolved.toString();
82}
83
84function resolveConnectionMetadata(handlers?: WsProtocolLifecycleHandlers): WsConnectionMetadata {
85 return {
86 connectionLabel: handlers?.getConnectionLabel?.() ?? null,
87 versionMismatchHint: handlers?.getVersionMismatchHint?.() ?? null,
88 };
89}
90
91type ComposedWsProtocolLifecycleHandlers = Required<
92 Pick<WsProtocolLifecycleHandlers, "isActive" | "onAttempt" | "onOpen" | "onError" | "onClose">
93>;
94
95function defaultLifecycleHandlers(
96 handlers?: WsProtocolLifecycleHandlers,
97): ComposedWsProtocolLifecycleHandlers {
98 return {
99 isActive: () => true,
100 onAttempt: (socketUrl) => {
101 recordWsConnectionAttempt(socketUrl, resolveConnectionMetadata(handlers));
102 },
103 onOpen: () => {
104 recordWsConnectionOpened(resolveConnectionMetadata(handlers));
105 },
106 onError: (message) => {
107 clearAllTrackedRpcRequests();
108 recordWsConnectionErrored(message, resolveConnectionMetadata(handlers));
109 },
110 onClose: (details, context) => {
111 clearAllTrackedRpcRequests();
112 if (context.intentional) {
113 return;
114 }
115 recordWsConnectionClosed(details, resolveConnectionMetadata(handlers));
116 },
117 };
118}
119
120function composeLifecycleHandlers(
121 handlers?: WsProtocolLifecycleHandlers,
122): ComposedWsProtocolLifecycleHandlers {
123 const defaults = defaultLifecycleHandlers(handlers);
124 const isActive = handlers?.isActive ?? defaults.isActive;
125
126 return {
127 isActive,
128 onAttempt: (socketUrl) => {
129 if (!isActive()) {
130 return;
131 }
132 defaults.onAttempt(socketUrl);
133 handlers?.onAttempt?.(socketUrl);
134 },
135 onOpen: () => {
136 if (!isActive()) {
137 return;
138 }
139 defaults.onOpen();
140 handlers?.onOpen?.();
141 },
142 onError: (message) => {
143 if (!isActive()) {
144 return;
145 }
146 defaults.onError(message);
147 handlers?.onError?.(message);
148 },
149 onClose: (details, context) => {
150 if (!isActive()) {
151 return;
152 }
153 defaults.onClose(details, context);
154 handlers?.onClose?.(details, context);
155 },
156 };
157}
158
159export function createWsRpcProtocolLayer(
160 url: WsRpcProtocolSocketUrlProvider,
161 handlers?: WsProtocolLifecycleHandlers,
162) {
163 const lifecycle = composeLifecycleHandlers(handlers);
164 const resolvedUrl =
165 typeof url === "function"
166 ? Effect.promise(() => url()).pipe(
167 Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)),
168 Effect.tapError((error) =>
169 Effect.sync(() => {
170 lifecycle.onError(formatSocketErrorMessage(error));
171 }),
172 ),
173 Effect.orDie,
174 )
175 : resolveWsRpcSocketUrl(url);
176
177 const trackingWebSocketConstructorLayer = Layer.succeed(
178 Socket.WebSocketConstructor,
179 (socketUrl, protocols) => {
180 lifecycle.onAttempt(socketUrl);
181 const socket = new globalThis.WebSocket(socketUrl, protocols);
182
183 socket.addEventListener(
184 "open",
185 () => {
186 lifecycle.onOpen();
187 },
188 { once: true },
189 );
190 socket.addEventListener(
191 "error",
192 () => {
193 lifecycle.onError("Unable to connect to the T3 server WebSocket.");
194 },
195 { once: true },
196 );
197 socket.addEventListener(
198 "close",
199 (event) => {
200 lifecycle.onClose(
201 {
202 code: event.code,
203 reason: event.reason,
204 },
205 {
206 intentional: handlers?.isCloseIntentional?.() ?? false,
207 },
208 );
209 },
210 { once: true },
211 );
212
213 return socket;
214 },
215 );
216 const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe(
217 Layer.provide(trackingWebSocketConstructorLayer),
218 );
219 const retryPolicy = Schedule.addDelay(Schedule.recurs(WS_RECONNECT_MAX_RETRIES), (retryCount) =>
220 Effect.succeed(Duration.millis(getWsReconnectDelayMsForRetry(retryCount) ?? 0)),
221 );
222 const protocolLayer = Layer.effect(
223 RpcClient.Protocol,
224 Effect.map(
225 RpcClient.makeProtocolSocket({
226 retryPolicy,
227 retryTransientErrors: true,
228 }),
229 (protocol) => ({
230 ...protocol,
231 run: (clientId, writeResponse) =>
232 protocol.run(clientId, (response) => {
233 if (response._tag === "ClientProtocolError" || response._tag === "Defect") {
234 clearAllTrackedRpcRequests();
235 }
236 return writeResponse(response);
237 }),
238 }),
239 ),
240 );
241 const requestHooksLayer = Layer.succeed(
242 RpcClient.RequestHooks,
243 RpcClient.RequestHooks.of({
244 onRequestStart: (info) =>
245 Effect.sync(() => {
246 if (!lifecycle.isActive()) {
247 return;
248 }
249 handlers?.onRequestStart?.({
250 id: String(info.id),
251 tag: info.tag,
252 stream: info.stream,
253 });
254 trackRpcRequestSent(String(info.id), info.tag);
255 }),
256 onRequestChunk: (info) =>
257 Effect.sync(() => {
258 if (!lifecycle.isActive()) {
259 return;
260 }
261 handlers?.onRequestChunk?.({
262 id: String(info.id),
263 tag: info.tag,
264 chunkCount: info.chunkCount,
265 });
266 acknowledgeRpcRequest(String(info.id));
267 }),
268 onRequestExit: (info) =>
269 Effect.sync(() => {
270 if (!lifecycle.isActive()) {
271 return;
272 }
273 handlers?.onRequestExit?.({
274 id: String(info.id),
275 tag: info.tag,
276 stream: info.stream,
277 });
278 acknowledgeRpcRequest(String(info.id));
279 }),
280 onRequestInterrupt: (info) =>
281 Effect.sync(() => {
282 if (!lifecycle.isActive()) {
283 return;
284 }
285 handlers?.onRequestInterrupt?.({
286 id: String(info.id),
287 ...(info.tag === undefined ? {} : { tag: info.tag }),
288 });
289 acknowledgeRpcRequest(String(info.id));
290 }),
291 }),
292 );
293 const connectionHooksLayer = Layer.succeed(
294 RpcClient.ConnectionHooks,
295 RpcClient.ConnectionHooks.of({
296 onConnect: Effect.void,
297 onDisconnect: Effect.void,
298 onPing: Effect.sync(() => {
299 if (lifecycle.isActive()) {
300 handlers?.onHeartbeatPing?.();
301 }
302 }),
303 onPong: Effect.sync(() => {
304 if (lifecycle.isActive()) {
305 handlers?.onHeartbeatPong?.();
306 }
307 }),
308 onPingTimeout: Effect.sync(() => {
309 if (lifecycle.isActive()) {
310 clearAllTrackedRpcRequests();
311 recordWsConnectionErrored(
312 "WebSocket heartbeat timed out.",
313 resolveConnectionMetadata(handlers),
314 );
315 handlers?.onHeartbeatTimeout?.();
316 }
317 }),
318 }),
319 );
320
321 return Layer.mergeAll(
322 protocolLayer.pipe(
323 Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)),
324 ),
325 requestHooksLayer,
326 connectionHooksLayer,
327 );
328}
apps/web/src/rpc/serverState.ts+1-1
...@@ -14,7 +14,7 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";...@@ -14,7 +14,7 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
14import { Atom } from "effect/unstable/reactivity";14import { Atom } from "effect/unstable/reactivity";
15import { useCallback, useRef } from "react";15import { useCallback, useRef } from "react";
1616
17import type { WsRpcClient } from "./wsRpcClient";17import type { WsRpcClient } from "@t3tools/client-runtime";
18import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";18import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";
1919
20export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"];20export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"];
apps/web/src/rpc/transportError.test.ts deleted-24
...@@ -1,24 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError";
4
5describe("transportError", () => {
6 it("detects websocket transport failures", () => {
7 expect(isTransportConnectionErrorMessage("SocketCloseError: 1006")).toBe(true);
8 expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe(
9 true,
10 );
11 expect(isTransportConnectionErrorMessage("SocketOpenError: Timeout")).toBe(true);
12 });
13
14 it("preserves non-transport thread errors", () => {
15 expect(sanitizeThreadErrorMessage("Turn failed")).toBe("Turn failed");
16 expect(sanitizeThreadErrorMessage("Select a base branch before sending.")).toBe(
17 "Select a base branch before sending.",
18 );
19 });
20
21 it("drops transport failures from thread surfaces", () => {
22 expect(sanitizeThreadErrorMessage("SocketCloseError: 1006")).toBeNull();
23 });
24});
apps/web/src/rpc/transportError.ts+4-23
...@@ -1,23 +1,4 @@...@@ -1,23 +1,4 @@
1const TRANSPORT_ERROR_PATTERNS = [1export {
2 /\bSocketCloseError\b/i,2 isTransportConnectionErrorMessage,
3 /\bSocketOpenError\b/i,3 sanitizeThreadErrorMessage,
4 /Unable to connect to the T3 server WebSocket\./i,4} from "@t3tools/client-runtime";
5 /\bping timeout\b/i,
6] as const;
7
8export function isTransportConnectionErrorMessage(message: string | null | undefined): boolean {
9 if (typeof message !== "string") {
10 return false;
11 }
12
13 const normalizedMessage = message.trim();
14 if (normalizedMessage.length === 0) {
15 return false;
16 }
17
18 return TRANSPORT_ERROR_PATTERNS.some((pattern) => pattern.test(normalizedMessage));
19}
20
21export function sanitizeThreadErrorMessage(message: string | null | undefined): string | null {
22 return isTransportConnectionErrorMessage(message) ? null : (message ?? null);
23}
apps/web/src/rpc/wsConnectionState.ts+6-12
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { DEFAULT_RECONNECT_BACKOFF, getReconnectDelayMs } from "@t3tools/client-runtime";
2import { Atom } from "effect/unstable/reactivity";3import { Atom } from "effect/unstable/reactivity";
34
4import { appAtomRegistry } from "./atomRegistry";5import { appAtomRegistry } from "./atomRegistry";
...@@ -6,10 +7,10 @@ import { appAtomRegistry } from "./atomRegistry";...@@ -6,10 +7,10 @@ import { appAtomRegistry } from "./atomRegistry";
6export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting";7export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting";
7export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting";8export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting";
89
9export const WS_RECONNECT_INITIAL_DELAY_MS = 1_000;10export const WS_RECONNECT_INITIAL_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.initialDelayMs;
10export const WS_RECONNECT_BACKOFF_FACTOR = 2;11export const WS_RECONNECT_BACKOFF_FACTOR = DEFAULT_RECONNECT_BACKOFF.backoffFactor;
11export const WS_RECONNECT_MAX_DELAY_MS = 64_000;12export const WS_RECONNECT_MAX_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.maxDelayMs;
12export const WS_RECONNECT_MAX_RETRIES = 7;13export const WS_RECONNECT_MAX_RETRIES = DEFAULT_RECONNECT_BACKOFF.maxRetries!;
13export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1;14export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1;
1415
15export interface WsConnectionStatus {16export interface WsConnectionStatus {
...@@ -201,14 +202,7 @@ export function useWsConnectionStatus(): WsConnectionStatus {...@@ -201,14 +202,7 @@ export function useWsConnectionStatus(): WsConnectionStatus {
201}202}
202203
203export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null {204export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null {
204 if (!Number.isInteger(retryIndex) || retryIndex < 0 || retryIndex >= WS_RECONNECT_MAX_RETRIES) {205 return getReconnectDelayMs(retryIndex);
205 return null;
206 }
207
208 return Math.min(
209 Math.round(WS_RECONNECT_INITIAL_DELAY_MS * WS_RECONNECT_BACKOFF_FACTOR ** retryIndex),
210 WS_RECONNECT_MAX_DELAY_MS,
211 );
212}206}
213207
214function applyDisconnectState(208function applyDisconnectState(
apps/web/src/rpc/wsRpcClient.test.ts deleted-105
...@@ -1,105 +0,0 @@
1import type {
2 VcsStatusLocalResult,
3 VcsStatusRemoteResult,
4 VcsStatusStreamEvent,
5} from "@t3tools/contracts";
6import { describe, expect, it, vi } from "vitest";
7
8vi.mock("./wsTransport", () => ({
9 WsTransport: class WsTransport {
10 dispose = vi.fn(async () => undefined);
11 reconnect = vi.fn(async () => undefined);
12 request = vi.fn();
13 requestStream = vi.fn();
14 subscribe = vi.fn(() => () => undefined);
15 },
16}));
17
18import { createWsRpcClient } from "./wsRpcClient";
19import { type WsTransport } from "./wsTransport";
20
21const baseLocalStatus: VcsStatusLocalResult = {
22 isRepo: true,
23 hasPrimaryRemote: true,
24 isDefaultRef: false,
25 refName: "feature/demo",
26 hasWorkingTreeChanges: false,
27 workingTree: { files: [], insertions: 0, deletions: 0 },
28};
29
30const baseRemoteStatus: VcsStatusRemoteResult = {
31 hasUpstream: true,
32 aheadCount: 0,
33 behindCount: 0,
34 pr: null,
35};
36
37describe("wsRpcClient", () => {
38 it("reduces vcs status stream events into flat status snapshots", () => {
39 const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => {
40 for (const event of [
41 {
42 _tag: "snapshot",
43 local: baseLocalStatus,
44 remote: null,
45 },
46 {
47 _tag: "remoteUpdated",
48 remote: baseRemoteStatus,
49 },
50 {
51 _tag: "localUpdated",
52 local: {
53 ...baseLocalStatus,
54 hasWorkingTreeChanges: true,
55 },
56 },
57 ] satisfies VcsStatusStreamEvent[]) {
58 listener(event as TValue);
59 }
60 return () => undefined;
61 });
62
63 const transport = {
64 dispose: vi.fn(async () => undefined),
65 reconnect: vi.fn(async () => undefined),
66 request: vi.fn(),
67 requestStream: vi.fn(),
68 subscribe,
69 } satisfies Pick<
70 WsTransport,
71 "dispose" | "reconnect" | "request" | "requestStream" | "subscribe"
72 >;
73
74 const client = createWsRpcClient(transport as unknown as WsTransport);
75 const listener = vi.fn();
76
77 client.vcs.onStatus({ cwd: "/repo" }, listener);
78
79 expect(listener.mock.calls).toEqual([
80 [
81 {
82 ...baseLocalStatus,
83 hasUpstream: false,
84 aheadCount: 0,
85 behindCount: 0,
86 aheadOfDefaultCount: 0,
87 pr: null,
88 },
89 ],
90 [
91 {
92 ...baseLocalStatus,
93 ...baseRemoteStatus,
94 },
95 ],
96 [
97 {
98 ...baseLocalStatus,
99 ...baseRemoteStatus,
100 hasWorkingTreeChanges: true,
101 },
102 ],
103 ]);
104 });
105});
apps/web/src/rpc/wsRpcClient.ts deleted-323
...@@ -1,323 +0,0 @@
1import {
2 type GitActionProgressEvent,
3 type GitRunStackedActionInput,
4 type GitRunStackedActionResult,
5 type VcsStatusResult,
6 type VcsStatusStreamEvent,
7 type LocalApi,
8 ORCHESTRATION_WS_METHODS,
9 type ServerSettingsPatch,
10 WS_METHODS,
11} from "@t3tools/contracts";
12import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
13import * as Effect from "effect/Effect";
14import * as Stream from "effect/Stream";
15
16import { type WsRpcProtocolClient } from "./protocol";
17import { resetWsReconnectBackoff } from "./wsConnectionState";
18import { WsTransport } from "./wsTransport";
19
20type RpcTag = keyof WsRpcProtocolClient & string;
21type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];
22type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];
23
24interface StreamSubscriptionOptions {
25 readonly onResubscribe?: () => void;
26}
27
28type RpcUnaryMethod<TTag extends RpcTag> =
29 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
30 ? (input: RpcInput<TTag>) => Promise<TSuccess>
31 : never;
32
33type RpcUnaryNoArgMethod<TTag extends RpcTag> =
34 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
35 ? () => Promise<TSuccess>
36 : never;
37
38type RpcStreamMethod<TTag extends RpcTag> =
39 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any>
40 ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void
41 : never;
42
43type RpcInputStreamMethod<TTag extends RpcTag> =
44 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any>
45 ? (
46 input: RpcInput<TTag>,
47 listener: (event: TEvent) => void,
48 options?: StreamSubscriptionOptions,
49 ) => () => void
50 : never;
51
52interface GitRunStackedActionOptions {
53 readonly onProgress?: (event: GitActionProgressEvent) => void;
54}
55
56export interface WsRpcClient {
57 readonly dispose: () => Promise<void>;
58 readonly reconnect: () => Promise<void>;
59 readonly isHeartbeatFresh: () => boolean;
60 readonly terminal: {
61 readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>;
62 readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>;
63 readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>;
64 readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>;
65 readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>;
66 readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>;
67 readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>;
68 };
69 readonly projects: {
70 readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;
71 readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;
72 };
73 readonly filesystem: {
74 readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>;
75 };
76 readonly sourceControl: {
77 readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>;
78 readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>;
79 readonly publishRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlPublishRepository>;
80 };
81 readonly shell: {
82 readonly openInEditor: (input: {
83 readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0];
84 readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1];
85 }) => ReturnType<LocalApi["shell"]["openInEditor"]>;
86 };
87 readonly vcs: {
88 readonly pull: RpcUnaryMethod<typeof WS_METHODS.vcsPull>;
89 readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.vcsRefreshStatus>;
90 readonly onStatus: (
91 input: RpcInput<typeof WS_METHODS.subscribeVcsStatus>,
92 listener: (status: VcsStatusResult) => void,
93 options?: StreamSubscriptionOptions,
94 ) => () => void;
95 readonly listRefs: RpcUnaryMethod<typeof WS_METHODS.vcsListRefs>;
96 readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsCreateWorktree>;
97 readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsRemoveWorktree>;
98 readonly createRef: RpcUnaryMethod<typeof WS_METHODS.vcsCreateRef>;
99 readonly switchRef: RpcUnaryMethod<typeof WS_METHODS.vcsSwitchRef>;
100 readonly init: RpcUnaryMethod<typeof WS_METHODS.vcsInit>;
101 };
102 /**
103 * Git-specific workflows. Local repository mechanics live under `vcs`.
104 */
105 readonly git: {
106 readonly runStackedAction: (
107 input: GitRunStackedActionInput,
108 options?: GitRunStackedActionOptions,
109 ) => Promise<GitRunStackedActionResult>;
110 readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>;
111 readonly preparePullRequestThread: RpcUnaryMethod<
112 typeof WS_METHODS.gitPreparePullRequestThread
113 >;
114 };
115 readonly server: {
116 readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>;
117 /**
118 * Refresh provider snapshots. Pass `{ instanceId }` to refresh a single
119 * configured instance; pass no argument (or `{}`) to refresh all.
120 */
121 readonly refreshProviders: (
122 input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>,
123 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>;
124 readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>;
125 readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;
126 readonly removeKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverRemoveKeybinding>;
127 readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;
128 readonly updateSettings: (
129 patch: ServerSettingsPatch,
130 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>;
131 readonly discoverSourceControl: RpcUnaryNoArgMethod<
132 typeof WS_METHODS.serverDiscoverSourceControl
133 >;
134 readonly getTraceDiagnostics: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetTraceDiagnostics>;
135 readonly getProcessDiagnostics: RpcUnaryNoArgMethod<
136 typeof WS_METHODS.serverGetProcessDiagnostics
137 >;
138 readonly getProcessResourceHistory: RpcUnaryMethod<
139 typeof WS_METHODS.serverGetProcessResourceHistory
140 >;
141 readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>;
142 readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>;
143 readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>;
144 readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>;
145 };
146 readonly orchestration: {
147 readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>;
148 readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>;
149 readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>;
150 readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod<
151 typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot
152 >;
153 readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>;
154 readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>;
155 };
156}
157
158export function createWsRpcClient(transport: WsTransport): WsRpcClient {
159 return {
160 dispose: () => transport.dispose(),
161 reconnect: async () => {
162 resetWsReconnectBackoff();
163 await transport.reconnect();
164 },
165 isHeartbeatFresh: () => transport.isHeartbeatFresh(),
166 terminal: {
167 open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),
168 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),
169 resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)),
170 clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)),
171 restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),
172 close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),
173 onEvent: (listener, options) =>
174 transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener, {
175 ...options,
176 tag: WS_METHODS.subscribeTerminalEvents,
177 }),
178 },
179 projects: {
180 searchEntries: (input) =>
181 transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)),
182 writeFile: (input) =>
183 transport.request((client) => client[WS_METHODS.projectsWriteFile](input)),
184 },
185 filesystem: {
186 browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
187 },
188 sourceControl: {
189 lookupRepository: (input) =>
190 transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)),
191 cloneRepository: (input) =>
192 transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)),
193 publishRepository: (input) =>
194 transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)),
195 },
196 shell: {
197 openInEditor: (input) =>
198 transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)),
199 },
200 vcs: {
201 pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)),
202 refreshStatus: (input) =>
203 transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)),
204 onStatus: (input, listener, options) => {
205 let current: VcsStatusResult | null = null;
206 return transport.subscribe(
207 (client) => client[WS_METHODS.subscribeVcsStatus](input),
208 (event: VcsStatusStreamEvent) => {
209 current = applyGitStatusStreamEvent(current, event);
210 listener(current);
211 },
212 { ...options, tag: WS_METHODS.subscribeVcsStatus },
213 );
214 },
215 listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)),
216 createWorktree: (input) =>
217 transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)),
218 removeWorktree: (input) =>
219 transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)),
220 createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)),
221 switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)),
222 init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)),
223 },
224 git: {
225 runStackedAction: async (input, options) => {
226 let result: GitRunStackedActionResult | null = null;
227
228 await transport.requestStream(
229 (client) => client[WS_METHODS.gitRunStackedAction](input),
230 (event) => {
231 options?.onProgress?.(event);
232 if (event.kind === "action_finished") {
233 result = event.result;
234 }
235 },
236 );
237
238 if (result) {
239 return result;
240 }
241
242 throw new Error("Git action stream completed without a final result.");
243 },
244 resolvePullRequest: (input) =>
245 transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)),
246 preparePullRequestThread: (input) =>
247 transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)),
248 },
249 server: {
250 getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
251 refreshProviders: (input) =>
252 transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})),
253 updateProvider: (input) =>
254 transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)),
255 upsertKeybinding: (input) =>
256 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
257 removeKeybinding: (input) =>
258 transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)),
259 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
260 updateSettings: (patch) =>
261 transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })),
262 discoverSourceControl: () =>
263 transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})),
264 getTraceDiagnostics: () =>
265 transport.request((client) =>
266 client[WS_METHODS.serverGetTraceDiagnostics]({}).pipe(Effect.withTracerEnabled(false)),
267 ),
268 getProcessDiagnostics: () =>
269 transport.request((client) =>
270 client[WS_METHODS.serverGetProcessDiagnostics]({}).pipe(Effect.withTracerEnabled(false)),
271 ),
272 getProcessResourceHistory: (input) =>
273 transport.request((client) =>
274 client[WS_METHODS.serverGetProcessResourceHistory](input).pipe(
275 Effect.withTracerEnabled(false),
276 ),
277 ),
278 signalProcess: (input) =>
279 transport.request((client) =>
280 client[WS_METHODS.serverSignalProcess](input).pipe(Effect.withTracerEnabled(false)),
281 ),
282 subscribeConfig: (listener, options) =>
283 transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener, {
284 ...options,
285 tag: WS_METHODS.subscribeServerConfig,
286 }),
287 subscribeLifecycle: (listener, options) =>
288 transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener, {
289 ...options,
290 tag: WS_METHODS.subscribeServerLifecycle,
291 }),
292 subscribeAuthAccess: (listener, options) =>
293 transport.subscribe((client) => client[WS_METHODS.subscribeAuthAccess]({}), listener, {
294 ...options,
295 tag: WS_METHODS.subscribeAuthAccess,
296 }),
297 },
298 orchestration: {
299 dispatchCommand: (input) =>
300 transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)),
301 getTurnDiff: (input) =>
302 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)),
303 getFullThreadDiff: (input) =>
304 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)),
305 getArchivedShellSnapshot: () =>
306 transport.request((client) =>
307 client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}),
308 ),
309 subscribeShell: (listener, options) =>
310 transport.subscribe(
311 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),
312 listener,
313 { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeShell },
314 ),
315 subscribeThread: (input, listener, options) =>
316 transport.subscribe(
317 (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input),
318 listener,
319 { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeThread },
320 ),
321 },
322 };
323}
apps/web/src/rpc/wsTransport.test.ts+1-826
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts";1import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import * as Stream from "effect/Stream";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
54
6import {5import {
...@@ -155,51 +154,7 @@ afterEach(async () => {...@@ -155,51 +154,7 @@ afterEach(async () => {
155 vi.restoreAllMocks();154 vi.restoreAllMocks();
156});155});
157156
158describe("WsTransport", () => {157describe("WsTransport (web instrumentation)", () => {
159 it("normalizes root websocket urls to /ws and preserves query params", async () => {
160 const transport = createTransport("ws://localhost:3020/?token=secret-token");
161
162 await waitFor(() => {
163 expect(sockets).toHaveLength(1);
164 });
165
166 expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token");
167 await transport.dispose();
168 });
169
170 it("uses an explicit secure websocket base url", async () => {
171 const transport = createTransport("wss://app.example.com");
172
173 await waitFor(() => {
174 expect(sockets).toHaveLength(1);
175 });
176
177 expect(getSocket().url).toBe("wss://app.example.com/ws");
178 await transport.dispose();
179 });
180
181 it("uses an explicit insecure websocket base url for remote backends", async () => {
182 const transport = createTransport("ws://192.168.1.44:3773");
183
184 await waitFor(() => {
185 expect(sockets).toHaveLength(1);
186 });
187
188 expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws");
189 await transport.dispose();
190 });
191
192 it("supports async websocket url providers", async () => {
193 const transport = createTransport(async () => "wss://remote.example.com/?wsToken=dynamic");
194
195 await waitFor(() => {
196 expect(sockets).toHaveLength(1);
197 });
198
199 expect(getSocket().url).toBe("wss://remote.example.com/ws?wsToken=dynamic");
200 await transport.dispose();
201 });
202
203 it("tracks initial connection failures for the app error state", async () => {158 it("tracks initial connection failures for the app error state", async () => {
204 const transport = createTransport("ws://localhost:3020");159 const transport = createTransport("ws://localhost:3020");
205160
...@@ -306,192 +261,6 @@ describe("WsTransport", () => {...@@ -306,192 +261,6 @@ describe("WsTransport", () => {
306 await transport.dispose();261 await transport.dispose();
307 });262 });
308263
309 it("does not report an intentional dispose as a reconnectable disconnect", async () => {
310 const onClose = vi.fn();
311 const transport = createTransport("ws://localhost:3020", {
312 getConnectionLabel: () => "Remote Mac mini",
313 onClose,
314 });
315
316 await waitFor(() => {
317 expect(sockets).toHaveLength(1);
318 });
319
320 const socket = getSocket();
321 socket.open();
322
323 await waitFor(() => {
324 expect(getWsConnectionStatus()).toMatchObject({
325 connectionLabel: "Remote Mac mini",
326 hasConnected: true,
327 phase: "connected",
328 });
329 });
330
331 await transport.dispose();
332
333 expect(onClose).not.toHaveBeenCalled();
334 expect(getWsConnectionStatus()).toMatchObject({
335 connectionLabel: "Remote Mac mini",
336 phase: "connected",
337 reconnectPhase: "idle",
338 });
339 });
340
341 it("reconnects the websocket session without disposing the transport", async () => {
342 const transport = createTransport("ws://localhost:3020");
343
344 await waitFor(() => {
345 expect(sockets).toHaveLength(1);
346 });
347
348 const firstSocket = getSocket();
349 firstSocket.open();
350
351 await waitFor(() => {
352 expect(getWsConnectionStatus()).toMatchObject({
353 hasConnected: true,
354 phase: "connected",
355 });
356 });
357
358 await transport.reconnect();
359
360 await waitFor(() => {
361 expect(sockets).toHaveLength(2);
362 });
363
364 const secondSocket = getSocket();
365 expect(secondSocket).not.toBe(firstSocket);
366 expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED);
367 expect(getWsConnectionStatus()).toMatchObject({
368 closeCode: null,
369 closeReason: null,
370 phase: "connecting",
371 });
372
373 const requestPromise = transport.request((client) =>
374 client[WS_METHODS.serverUpsertKeybinding]({
375 command: "terminal.toggle",
376 key: "ctrl+k",
377 }),
378 );
379
380 secondSocket.open();
381
382 await waitFor(() => {
383 expect(secondSocket.sent).toHaveLength(1);
384 });
385
386 const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string };
387 secondSocket.serverMessage(
388 JSON.stringify({
389 _tag: "Exit",
390 requestId: requestMessage.id,
391 exit: {
392 _tag: "Success",
393 value: {
394 keybindings: [],
395 issues: [],
396 },
397 },
398 }),
399 );
400
401 await expect(requestPromise).resolves.toEqual({
402 keybindings: [],
403 issues: [],
404 });
405
406 await transport.dispose();
407 });
408
409 it("ignores stale socket lifecycle events after a reconnect starts a new session", async () => {
410 const onClose = vi.fn();
411 const transport = createTransport("ws://localhost:3020", { onClose });
412
413 await waitFor(() => {
414 expect(sockets).toHaveLength(1);
415 });
416
417 const firstSocket = getSocket();
418 firstSocket.open();
419
420 await waitFor(() => {
421 expect(getWsConnectionStatus()).toMatchObject({
422 hasConnected: true,
423 phase: "connected",
424 });
425 });
426
427 await transport.reconnect();
428
429 await waitFor(() => {
430 expect(sockets).toHaveLength(2);
431 });
432
433 expect(onClose).not.toHaveBeenCalled();
434 expect(getWsConnectionStatus()).toMatchObject({
435 closeCode: null,
436 closeReason: null,
437 phase: "connecting",
438 });
439
440 const secondSocket = getSocket();
441 secondSocket.open();
442
443 await waitFor(() => {
444 expect(getWsConnectionStatus()).toMatchObject({
445 phase: "connected",
446 });
447 });
448
449 firstSocket.close(1006, "stale close");
450
451 expect(onClose).not.toHaveBeenCalled();
452 expect(getWsConnectionStatus()).toMatchObject({
453 closeCode: null,
454 closeReason: null,
455 phase: "connected",
456 });
457
458 await transport.dispose();
459 });
460
461 it("ignores stale socket close events after the transport is disposed", async () => {
462 const onClose = vi.fn();
463 const transport = createTransport("ws://localhost:3020", { onClose });
464
465 await waitFor(() => {
466 expect(sockets).toHaveLength(1);
467 });
468
469 const firstSocket = getSocket();
470 firstSocket.open();
471
472 await waitFor(() => {
473 expect(getWsConnectionStatus()).toMatchObject({
474 hasConnected: true,
475 phase: "connected",
476 });
477 });
478
479 await transport.reconnect();
480
481 await waitFor(() => {
482 expect(sockets).toHaveLength(2);
483 });
484
485 const secondSocket = getSocket();
486 secondSocket.open();
487 await transport.dispose();
488
489 onClose.mockClear();
490 firstSocket.close(1006, "stale close after dispose");
491
492 expect(onClose).not.toHaveBeenCalled();
493 });
494
495 it("marks unary requests as slow until the first server ack arrives", async () => {264 it("marks unary requests as slow until the first server ack arrives", async () => {
496 const slowAckThresholdMs = 25;265 const slowAckThresholdMs = 25;
497 setSlowRpcAckThresholdMsForTests(slowAckThresholdMs);266 setSlowRpcAckThresholdMsForTests(slowAckThresholdMs);
...@@ -598,600 +367,6 @@ describe("WsTransport", () => {...@@ -598,600 +367,6 @@ describe("WsTransport", () => {
598 await transport.dispose();367 await transport.dispose();
599 }, 5_000);368 }, 5_000);
600369
601 it("sends unary RPC requests and resolves successful exits", async () => {
602 const transport = createTransport("ws://localhost:3020");
603
604 const requestPromise = transport.request((client) =>
605 client[WS_METHODS.serverUpsertKeybinding]({
606 command: "terminal.toggle",
607 key: "ctrl+k",
608 }),
609 );
610
611 await waitFor(() => {
612 expect(sockets).toHaveLength(1);
613 });
614
615 const socket = getSocket();
616 socket.open();
617
618 await waitFor(() => {
619 expect(socket.sent).toHaveLength(1);
620 });
621
622 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as {
623 _tag: string;
624 id: string;
625 payload: unknown;
626 tag: string;
627 };
628 expect(requestMessage).toMatchObject({
629 _tag: "Request",
630 tag: WS_METHODS.serverUpsertKeybinding,
631 payload: {
632 command: "terminal.toggle",
633 key: "ctrl+k",
634 },
635 });
636
637 socket.serverMessage(
638 JSON.stringify({
639 _tag: "Exit",
640 requestId: requestMessage.id,
641 exit: {
642 _tag: "Success",
643 value: {
644 keybindings: [],
645 issues: [],
646 },
647 },
648 }),
649 );
650
651 await expect(requestPromise).resolves.toEqual({
652 keybindings: [],
653 issues: [],
654 });
655
656 await transport.dispose();
657 });
658
659 it("delivers stream chunks to subscribers", async () => {
660 const transport = createTransport("ws://localhost:3020");
661 const listener = vi.fn();
662
663 const unsubscribe = transport.subscribe(
664 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
665 listener,
666 );
667 await waitFor(() => {
668 expect(sockets).toHaveLength(1);
669 });
670
671 const socket = getSocket();
672 socket.open();
673
674 await waitFor(() => {
675 expect(socket.sent).toHaveLength(1);
676 });
677
678 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
679 expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle);
680
681 const welcomeEvent = {
682 version: 1,
683 sequence: 1,
684 type: "welcome",
685 payload: {
686 environment: {
687 environmentId: "environment-local",
688 label: "Local environment",
689 platform: { os: "darwin", arch: "arm64" },
690 serverVersion: "0.0.0-test",
691 capabilities: { repositoryIdentity: true },
692 },
693 cwd: "/tmp/workspace",
694 projectName: "workspace",
695 },
696 };
697
698 socket.serverMessage(
699 JSON.stringify({
700 _tag: "Chunk",
701 requestId: requestMessage.id,
702 values: [welcomeEvent],
703 }),
704 );
705
706 await waitFor(() => {
707 expect(listener).toHaveBeenCalledWith(welcomeEvent);
708 });
709
710 unsubscribe();
711 await transport.dispose();
712 });
713
714 it("re-subscribes stream listeners after the stream exits", async () => {
715 const transport = createTransport("ws://localhost:3020");
716 const listener = vi.fn();
717 const onResubscribe = vi.fn();
718
719 const unsubscribe = transport.subscribe(
720 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
721 listener,
722 { onResubscribe },
723 );
724 await waitFor(() => {
725 expect(sockets).toHaveLength(1);
726 });
727
728 const socket = getSocket();
729 socket.open();
730
731 await waitFor(() => {
732 expect(socket.sent).toHaveLength(1);
733 });
734
735 const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
736 socket.serverMessage(
737 JSON.stringify({
738 _tag: "Chunk",
739 requestId: firstRequest.id,
740 values: [
741 {
742 version: 1,
743 sequence: 1,
744 type: "welcome",
745 payload: {
746 environment: {
747 environmentId: "environment-local",
748 label: "Local environment",
749 platform: { os: "darwin", arch: "arm64" },
750 serverVersion: "0.0.0-test",
751 capabilities: { repositoryIdentity: true },
752 },
753 cwd: "/tmp/one",
754 projectName: "one",
755 },
756 },
757 ],
758 }),
759 );
760 socket.serverMessage(
761 JSON.stringify({
762 _tag: "Exit",
763 requestId: firstRequest.id,
764 exit: {
765 _tag: "Success",
766 value: null,
767 },
768 }),
769 );
770
771 await waitFor(() => {
772 const nextRequest = socket.sent
773 .map((message) => JSON.parse(message) as { _tag?: string; id?: string })
774 .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
775 expect(nextRequest).toBeDefined();
776 });
777 expect(onResubscribe).toHaveBeenCalledOnce();
778
779 const secondRequest = socket.sent
780 .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string })
781 .find(
782 (message): message is { _tag: "Request"; id: string; tag: string } =>
783 message._tag === "Request" && message.id !== firstRequest.id,
784 );
785 if (!secondRequest) {
786 throw new Error("Expected a resubscribe request");
787 }
788 expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle);
789 expect(secondRequest.id).not.toBe(firstRequest.id);
790
791 const secondEvent = {
792 version: 1,
793 sequence: 2,
794 type: "welcome",
795 payload: {
796 environment: {
797 environmentId: "environment-local",
798 label: "Local environment",
799 platform: { os: "darwin", arch: "arm64" },
800 serverVersion: "0.0.0-test",
801 capabilities: { repositoryIdentity: true },
802 },
803 cwd: "/tmp/two",
804 projectName: "two",
805 },
806 };
807 socket.serverMessage(
808 JSON.stringify({
809 _tag: "Chunk",
810 requestId: secondRequest.id,
811 values: [secondEvent],
812 }),
813 );
814
815 await waitFor(() => {
816 expect(listener).toHaveBeenLastCalledWith(secondEvent);
817 });
818
819 unsubscribe();
820 await transport.dispose();
821 });
822
823 it("re-subscribes live stream listeners after an explicit transport reconnect", async () => {
824 const transport = createTransport("ws://localhost:3020");
825 const listener = vi.fn();
826 const onResubscribe = vi.fn();
827
828 const unsubscribe = transport.subscribe(
829 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
830 listener,
831 { onResubscribe },
832 );
833
834 await waitFor(() => {
835 expect(sockets).toHaveLength(1);
836 });
837
838 const firstSocket = getSocket();
839 firstSocket.open();
840
841 await waitFor(() => {
842 expect(firstSocket.sent).toHaveLength(1);
843 });
844
845 const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string };
846 const firstEvent = {
847 version: 1,
848 sequence: 1,
849 type: "welcome",
850 payload: {
851 environment: {
852 environmentId: "environment-local",
853 label: "Local environment",
854 platform: { os: "darwin", arch: "arm64" },
855 serverVersion: "0.0.0-test",
856 capabilities: { repositoryIdentity: true },
857 },
858 cwd: "/tmp/one",
859 projectName: "one",
860 },
861 };
862
863 firstSocket.serverMessage(
864 JSON.stringify({
865 _tag: "Chunk",
866 requestId: firstRequest.id,
867 values: [firstEvent],
868 }),
869 );
870
871 await waitFor(() => {
872 expect(listener).toHaveBeenLastCalledWith(firstEvent);
873 });
874
875 await transport.reconnect();
876
877 await waitFor(() => {
878 expect(sockets).toHaveLength(2);
879 });
880
881 const secondSocket = getSocket();
882 expect(secondSocket).not.toBe(firstSocket);
883 expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED);
884
885 secondSocket.open();
886
887 await waitFor(() => {
888 expect(secondSocket.sent).toHaveLength(1);
889 });
890
891 const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as {
892 id: string;
893 tag: string;
894 };
895 expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle);
896 expect(secondRequest.id).not.toBe(firstRequest.id);
897 expect(onResubscribe).toHaveBeenCalledOnce();
898
899 const secondEvent = {
900 version: 1,
901 sequence: 2,
902 type: "welcome",
903 payload: {
904 environment: {
905 environmentId: "environment-local",
906 label: "Local environment",
907 platform: { os: "darwin", arch: "arm64" },
908 serverVersion: "0.0.0-test",
909 capabilities: { repositoryIdentity: true },
910 },
911 cwd: "/tmp/two",
912 projectName: "two",
913 },
914 };
915
916 secondSocket.serverMessage(
917 JSON.stringify({
918 _tag: "Chunk",
919 requestId: secondRequest.id,
920 values: [secondEvent],
921 }),
922 );
923
924 await waitFor(() => {
925 expect(listener).toHaveBeenLastCalledWith(secondEvent);
926 });
927
928 unsubscribe();
929 await transport.dispose();
930 });
931
932 it("does not fire onResubscribe when the first stream attempt exits before any value", async () => {
933 const transport = createTransport("ws://localhost:3020");
934 const listener = vi.fn();
935 const onResubscribe = vi.fn();
936
937 const unsubscribe = transport.subscribe(
938 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
939 listener,
940 { onResubscribe },
941 );
942 await waitFor(() => {
943 expect(sockets).toHaveLength(1);
944 });
945
946 const socket = getSocket();
947 socket.open();
948
949 await waitFor(() => {
950 expect(socket.sent).toHaveLength(1);
951 });
952
953 const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
954 socket.serverMessage(
955 JSON.stringify({
956 _tag: "Exit",
957 requestId: firstRequest.id,
958 exit: {
959 _tag: "Success",
960 value: null,
961 },
962 }),
963 );
964
965 await waitFor(() => {
966 const nextRequest = socket.sent
967 .map((message) => JSON.parse(message) as { _tag?: string; id?: string })
968 .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
969 expect(nextRequest).toBeDefined();
970 });
971 expect(onResubscribe).not.toHaveBeenCalled();
972 expect(listener).not.toHaveBeenCalled();
973
974 unsubscribe();
975 await transport.dispose();
976 });
977
978 it("does not retry stream subscriptions after application-level failures", async () => {
979 const transport = createTransport("ws://localhost:3020");
980 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
981 let attempts = 0;
982
983 const unsubscribe = transport.subscribe(
984 () =>
985 Stream.suspend(() => {
986 attempts += 1;
987 return Stream.fail(new Error("Git command failed in GitCore.statusDetails"));
988 }),
989 vi.fn(),
990 { retryDelay: 10 },
991 );
992
993 await waitFor(() => {
994 expect(sockets).toHaveLength(1);
995 });
996
997 getSocket().open();
998
999 await waitFor(() => {
1000 expect(attempts).toBe(1);
1001 });
1002 await new Promise((resolve) => setTimeout(resolve, 50));
1003
1004 expect(attempts).toBe(1);
1005 expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", {
1006 error: "Git command failed in GitCore.statusDetails",
1007 });
1008 expect(warnSpy).not.toHaveBeenCalledWith(
1009 "WebSocket RPC subscription disconnected",
1010 expect.anything(),
1011 );
1012
1013 unsubscribe();
1014 await transport.dispose();
1015 });
1016
1017 it("keeps retrying stream subscriptions after transport failures", async () => {
1018 const transport = createTransport("ws://localhost:3020");
1019 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1020 let attempts = 0;
1021
1022 const unsubscribe = transport.subscribe(
1023 () =>
1024 Stream.suspend(() => {
1025 attempts += 1;
1026 return Stream.fail(new Error("SocketCloseError: WebSocket closed"));
1027 }),
1028 vi.fn(),
1029 { retryDelay: 10 },
1030 );
1031
1032 await waitFor(() => {
1033 expect(sockets).toHaveLength(1);
1034 });
1035
1036 getSocket().open();
1037
1038 await waitFor(() => {
1039 expect(attempts).toBeGreaterThanOrEqual(2);
1040 });
1041
1042 expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", {
1043 error: "SocketCloseError: WebSocket closed",
1044 });
1045
1046 unsubscribe();
1047 await transport.dispose();
1048 });
1049
1050 it("logs a transport disconnect once even when multiple subscriptions fail together", async () => {
1051 const transport = createTransport("ws://localhost:3020");
1052 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1053
1054 const unsubscribeA = transport.subscribe(
1055 () => Stream.fail(new Error("SocketCloseError: 1006")),
1056 vi.fn(),
1057 { retryDelay: 10 },
1058 );
1059 const unsubscribeB = transport.subscribe(
1060 () => Stream.fail(new Error("SocketCloseError: 1006")),
1061 vi.fn(),
1062 { retryDelay: 10 },
1063 );
1064
1065 await waitFor(() => {
1066 expect(sockets).toHaveLength(1);
1067 });
1068
1069 getSocket().open();
1070
1071 await waitFor(() => {
1072 expect(warnSpy).toHaveBeenCalledTimes(1);
1073 });
1074 expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", {
1075 error: "SocketCloseError: 1006",
1076 });
1077
1078 unsubscribeA();
1079 unsubscribeB();
1080 await transport.dispose();
1081 });
1082
1083 it("streams finite request events without re-subscribing", async () => {
1084 const transport = createTransport("ws://localhost:3020");
1085 const listener = vi.fn();
1086
1087 await waitFor(() => {
1088 expect(sockets).toHaveLength(1);
1089 });
1090 const socket = getSocket();
1091 socket.open();
1092
1093 const requestPromise = transport.requestStream(
1094 (client) =>
1095 client[WS_METHODS.gitRunStackedAction]({
1096 actionId: "action-1",
1097 cwd: "/repo",
1098 action: "commit",
1099 }),
1100 listener,
1101 );
1102
1103 await waitFor(() => {
1104 expect(socket.sent).toHaveLength(1);
1105 });
1106
1107 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
1108 const progressEvent = {
1109 actionId: "action-1",
1110 cwd: "/repo",
1111 action: "commit",
1112 kind: "phase_started",
1113 phase: "commit",
1114 label: "Committing...",
1115 } as const;
1116
1117 socket.serverMessage(
1118 JSON.stringify({
1119 _tag: "Chunk",
1120 requestId: requestMessage.id,
1121 values: [progressEvent],
1122 }),
1123 );
1124 socket.serverMessage(
1125 JSON.stringify({
1126 _tag: "Exit",
1127 requestId: requestMessage.id,
1128 exit: {
1129 _tag: "Success",
1130 value: null,
1131 },
1132 }),
1133 );
1134
1135 await expect(requestPromise).resolves.toBeUndefined();
1136 expect(listener).toHaveBeenCalledWith(progressEvent);
1137 expect(
1138 socket.sent.filter((message) => {
1139 const parsed = JSON.parse(message) as { _tag?: string; tag?: string };
1140 return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction;
1141 }),
1142 ).toHaveLength(1);
1143 await transport.dispose();
1144 });
1145
1146 it("closes the client scope on the transport runtime before disposing the runtime", async () => {
1147 const callOrder: string[] = [];
1148 let resolveClose!: () => void;
1149 const closePromise = new Promise<void>((resolve) => {
1150 resolveClose = resolve;
1151 });
1152
1153 const runtime = {
1154 runPromise: vi.fn(async () => {
1155 callOrder.push("close:start");
1156 await closePromise;
1157 callOrder.push("close:done");
1158 return undefined;
1159 }),
1160 dispose: vi.fn(async () => {
1161 callOrder.push("runtime:dispose");
1162 }),
1163 };
1164 const transport = {
1165 disposed: false,
1166 session: {
1167 clientScope: {} as never,
1168 runtime,
1169 },
1170 closeSession: (
1171 WsTransport.prototype as unknown as {
1172 closeSession: (session: {
1173 clientScope: unknown;
1174 runtime: { dispose: () => Promise<void>; runPromise: () => Promise<void> };
1175 }) => Promise<void>;
1176 }
1177 ).closeSession,
1178 } as unknown as WsTransport;
1179
1180 void WsTransport.prototype.dispose.call(transport);
1181
1182 expect(runtime.runPromise).toHaveBeenCalledTimes(1);
1183 expect(runtime.dispose).not.toHaveBeenCalled();
1184 expect((transport as unknown as { disposed: boolean }).disposed).toBe(true);
1185
1186 resolveClose();
1187
1188 await waitFor(() => {
1189 expect(runtime.dispose).toHaveBeenCalledTimes(1);
1190 });
1191
1192 expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]);
1193 });
1194
1195 it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => {370 it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => {
1196 await configureClientTracing({371 await configureClientTracing({
1197 exportIntervalMs: 10,372 exportIntervalMs: 10,
apps/web/src/rpc/wsTransport.ts+50-348
...@@ -1,361 +1,63 @@...@@ -1,361 +1,63 @@
1import * as Cause from "effect/Cause";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Layer from "effect/Layer";
6import * as ManagedRuntime from "effect/ManagedRuntime";
7import * as Option from "effect/Option";
8import * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";
10import { RpcClient } from "effect/unstable/rpc";
11
12import { ClientTracingLive } from "../observability/clientTracing";
13import { clearAllTrackedRpcRequests } from "./requestLatencyState";
14import {1import {
15 createWsRpcProtocolLayer,2 WsTransport as BaseWsTransport,
16 makeWsRpcProtocolClient,
17 type WsProtocolLifecycleHandlers,3 type WsProtocolLifecycleHandlers,
18 type WsRpcProtocolClient,
19 type WsRpcProtocolSocketUrlProvider,4 type WsRpcProtocolSocketUrlProvider,
20} from "./protocol";5 type WsTransportOptions,
21import { isTransportConnectionErrorMessage } from "./transportError";6} from "@t3tools/client-runtime";
227import { createWsRpcProtocolLayer as createSharedWsRpcProtocolLayer } from "@t3tools/client-runtime";
23interface SubscribeOptions {
24 readonly retryDelay?: Duration.Input;
25 readonly onResubscribe?: () => void;
26 readonly tag?: string;
27}
28
29interface RequestOptions {
30 readonly timeout?: Option.Option<Duration.Input>;
31}
32
33const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250);
34const NOOP: () => void = () => undefined;
35
36interface TransportSession {
37 readonly clientPromise: Promise<WsRpcProtocolClient>;
38 readonly clientScope: Scope.Closeable;
39 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
40}
418
42interface StreamRequestStartInfo {9import { ClientTracingLive } from "../observability/clientTracing";
43 readonly id: string;10import {
44 readonly tag: string;11 acknowledgeRpcRequest,
45 readonly stream: boolean;12 clearAllTrackedRpcRequests,
46}13 trackRpcRequestSent,
4714} from "./requestLatencyState";
48function formatErrorMessage(error: unknown): string {15import {
49 if (error instanceof Error && error.message.trim().length > 0) {16 recordWsConnectionAttempt,
50 return error.message;17 recordWsConnectionClosed,
51 }18 recordWsConnectionErrored,
52 return String(error);19 recordWsConnectionOpened,
20} from "./wsConnectionState";
21
22function createWsRpcProtocolLayer(
23 url: WsRpcProtocolSocketUrlProvider,
24 handlers?: WsProtocolLifecycleHandlers,
25) {
26 return createSharedWsRpcProtocolLayer(url, handlers, {
27 telemetryLifecycle: {
28 onAttempt: recordWsConnectionAttempt,
29 onOpen: recordWsConnectionOpened,
30 onError: (message) => {
31 clearAllTrackedRpcRequests();
32 recordWsConnectionErrored(message);
33 },
34 onClose: (details, context) => {
35 clearAllTrackedRpcRequests();
36 if (context.intentional) {
37 return;
38 }
39 recordWsConnectionClosed(details);
40 },
41 },
42 requestTelemetry: {
43 onRequestSent: trackRpcRequestSent,
44 onRequestAcknowledged: acknowledgeRpcRequest,
45 onClearTrackedRequests: clearAllTrackedRpcRequests,
46 },
47 });
53}48}
5449
55export class WsTransport {50const webWsTransportOptions = {
56 private readonly url: WsRpcProtocolSocketUrlProvider;51 tracingLayer: ClientTracingLive,
57 private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined;52 createProtocolLayer: createWsRpcProtocolLayer,
58 private disposed = false;53 onBeforeReconnect: () => clearAllTrackedRpcRequests(),
59 private hasReportedTransportDisconnect = false;54} satisfies WsTransportOptions;
60 private intentionalCloseDepth = 0;
61 private reconnectChain: Promise<void> = Promise.resolve();
62 private nextSessionId = 0;
63 private activeSessionId = 0;
64 private session: TransportSession;
65 private lastHeartbeatPongAt = 0;
66 private readonly streamRequestStartListeners = new Set<(info: StreamRequestStartInfo) => void>();
6755
56export class WsTransport extends BaseWsTransport {
68 constructor(57 constructor(
69 url: WsRpcProtocolSocketUrlProvider,58 url: WsRpcProtocolSocketUrlProvider,
70 lifecycleHandlers?: WsProtocolLifecycleHandlers,59 lifecycleHandlers?: WsProtocolLifecycleHandlers,
71 ) {60 ) {
72 this.url = url;61 super(url, lifecycleHandlers, webWsTransportOptions);
73 this.lifecycleHandlers = lifecycleHandlers;
74 this.session = this.createSession();
75 }
76
77 async request<TSuccess>(
78 execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>,
79 _options?: RequestOptions,
80 ): Promise<TSuccess> {
81 if (this.disposed) {
82 throw new Error("Transport disposed");
83 }
84
85 const session = this.session;
86 const client = await session.clientPromise;
87 return await session.runtime.runPromise(Effect.suspend(() => execute(client)));
88 }
89
90 async requestStream<TValue>(
91 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
92 listener: (value: TValue) => void,
93 ): Promise<void> {
94 if (this.disposed) {
95 throw new Error("Transport disposed");
96 }
97
98 const session = this.session;
99 const client = await session.clientPromise;
100 await session.runtime.runPromise(
101 Stream.runForEach(connect(client), (value) =>
102 Effect.sync(() => {
103 try {
104 listener(value);
105 } catch {
106 // Swallow listener errors so the stream can finish cleanly.
107 }
108 }),
109 ),
110 );
111 }
112
113 subscribe<TValue>(
114 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
115 listener: (value: TValue) => void,
116 options?: SubscribeOptions,
117 ): () => void {
118 if (this.disposed) {
119 return () => undefined;
120 }
121
122 let active = true;
123 let hasReceivedValue = false;
124 const retryDelayMs = Duration.toMillis(
125 Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS),
126 );
127 let cancelCurrentStream: () => void = NOOP;
128
129 void (async () => {
130 for (;;) {
131 if (!active || this.disposed) {
132 return;
133 }
134
135 const session = this.session;
136 try {
137 const runningStream = this.runStreamOnSession(
138 session,
139 connect,
140 listener,
141 {
142 ...(options?.tag === undefined ? {} : { tag: options.tag }),
143 ...(hasReceivedValue
144 ? {
145 onStarted: () => {
146 try {
147 options?.onResubscribe?.();
148 } catch {
149 // Swallow reconnect hook errors so the stream can recover.
150 }
151 },
152 }
153 : {}),
154 },
155 () => active,
156 () => {
157 this.hasReportedTransportDisconnect = false;
158 hasReceivedValue = true;
159 },
160 );
161 cancelCurrentStream = runningStream.cancel;
162 await runningStream.completed;
163 cancelCurrentStream = NOOP;
164 } catch (error) {
165 cancelCurrentStream = NOOP;
166 if (!active || this.disposed) {
167 return;
168 }
169
170 if (session !== this.session) {
171 continue;
172 }
173
174 const formattedError = formatErrorMessage(error);
175 if (!isTransportConnectionErrorMessage(formattedError)) {
176 console.warn("WebSocket RPC subscription failed", {
177 error: formattedError,
178 });
179 return;
180 }
181
182 if (!this.hasReportedTransportDisconnect) {
183 console.warn("WebSocket RPC subscription disconnected", {
184 error: formattedError,
185 });
186 }
187 this.hasReportedTransportDisconnect = true;
188 await sleep(retryDelayMs);
189 }
190 }
191 })();
192
193 return () => {
194 active = false;
195 cancelCurrentStream();
196 };
197 }
198
199 async reconnect() {
200 if (this.disposed) {
201 throw new Error("Transport disposed");
202 }
203
204 const reconnectOperation = this.reconnectChain.then(async () => {
205 if (this.disposed) {
206 throw new Error("Transport disposed");
207 }
208
209 clearAllTrackedRpcRequests();
210 this.lastHeartbeatPongAt = 0;
211 const previousSession = this.session;
212 this.session = this.createSession();
213 await this.closeSession(previousSession);
214 });
215
216 this.reconnectChain = reconnectOperation.catch(() => undefined);
217 await reconnectOperation;
218 }
219
220 isHeartbeatFresh(maxAgeMs = 15_000): boolean {
221 return this.lastHeartbeatPongAt > 0 && Date.now() - this.lastHeartbeatPongAt <= maxAgeMs;
222 }
223
224 async dispose() {
225 if (this.disposed) {
226 return;
227 }
228 this.disposed = true;
229 await this.closeSession(this.session);
230 }
231
232 private closeSession(session: TransportSession) {
233 this.intentionalCloseDepth += 1;
234 return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => {
235 this.intentionalCloseDepth -= 1;
236 session.runtime.dispose();
237 });
238 }62 }
239
240 private createSession(): TransportSession {
241 const sessionId = this.nextSessionId + 1;
242 this.nextSessionId = sessionId;
243 this.activeSessionId = sessionId;
244 const runtime = ManagedRuntime.make(
245 Layer.mergeAll(
246 createWsRpcProtocolLayer(this.url, {
247 ...this.lifecycleHandlers,
248 isActive: () => !this.disposed && this.activeSessionId === sessionId,
249 isCloseIntentional: () =>
250 this.disposed ||
251 this.intentionalCloseDepth > 0 ||
252 this.lifecycleHandlers?.isCloseIntentional?.() === true,
253 onHeartbeatPong: () => {
254 this.lastHeartbeatPongAt = Date.now();
255 this.lifecycleHandlers?.onHeartbeatPong?.();
256 },
257 onRequestStart: (info) => {
258 this.lifecycleHandlers?.onRequestStart?.(info);
259 if (!info.stream) {
260 return;
261 }
262 for (const listener of this.streamRequestStartListeners) {
263 listener(info);
264 }
265 },
266 }),
267 ClientTracingLive,
268 ),
269 );
270 const clientScope = runtime.runSync(Scope.make());
271 return {
272 runtime,
273 clientScope,
274 clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)),
275 };
276 }
277
278 private runStreamOnSession<TValue>(
279 session: TransportSession,
280 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
281 listener: (value: TValue) => void,
282 requestStart: {
283 readonly tag?: string;
284 readonly onStarted?: () => void;
285 },
286 isActive: () => boolean,
287 markValueReceived: () => void,
288 ): {
289 readonly cancel: () => void;
290 readonly completed: Promise<void>;
291 } {
292 let resolveCompleted!: () => void;
293 let rejectCompleted!: (error: unknown) => void;
294 const completed = new Promise<void>((resolve, reject) => {
295 resolveCompleted = resolve;
296 rejectCompleted = reject;
297 });
298 let requestStartListener: ((info: StreamRequestStartInfo) => void) | null = null;
299 if (requestStart.onStarted) {
300 requestStartListener = (info) => {
301 if (!isActive() || !info.stream) {
302 return;
303 }
304 if (requestStart.tag !== undefined && info.tag !== requestStart.tag) {
305 return;
306 }
307 requestStart.onStarted?.();
308 if (requestStartListener) {
309 this.streamRequestStartListeners.delete(requestStartListener);
310 requestStartListener = null;
311 }
312 };
313 this.streamRequestStartListeners.add(requestStartListener);
314 }
315 const cancel = session.runtime.runCallback(
316 Effect.promise(() => session.clientPromise).pipe(
317 Effect.flatMap((client) =>
318 Stream.runForEach(connect(client), (value) =>
319 Effect.sync(() => {
320 if (!isActive()) {
321 return;
322 }
323
324 markValueReceived();
325 try {
326 listener(value);
327 } catch {
328 // Swallow listener errors so the stream stays live.
329 }
330 }),
331 ),
332 ),
333 ),
334 {
335 onExit: (exit) => {
336 if (requestStartListener) {
337 this.streamRequestStartListeners.delete(requestStartListener);
338 requestStartListener = null;
339 }
340 if (Exit.isSuccess(exit)) {
341 resolveCompleted();
342 return;
343 }
344
345 rejectCompleted(Cause.squash(exit.cause));
346 },
347 },
348 );
349
350 return {
351 cancel,
352 completed,
353 };
354 }
355}
356
357function sleep(ms: number): Promise<void> {
358 return new Promise((resolve) => {
359 setTimeout(resolve, ms);
360 });
361}63}
apps/web/src/store.ts+12
...@@ -43,6 +43,12 @@ export interface EnvironmentState {...@@ -43,6 +43,12 @@ export interface EnvironmentState {
43 projectIds: ProjectId[];43 projectIds: ProjectId[];
44 projectById: Record<ProjectId, Project>;44 projectById: Record<ProjectId, Project>;
4545
46 // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY):
47 // Web still stores shell snapshots and thread details in this denormalized
48 // Zustand shape. Mobile uses createShellSnapshotManager and
49 // createThreadDetailManager from @t3tools/client-runtime. New shared behavior
50 // belongs in those managers/reducers, with a web adapter layered on top.
51 //
46 // ---------------------------------------------------------------------------52 // ---------------------------------------------------------------------------
47 // Thread bookkeeping — written by BOTH shell stream and detail stream.53 // Thread bookkeeping — written by BOTH shell stream and detail stream.
48 // Both streams ensure the thread is registered here; the bookkeeping is54 // Both streams ensure the thread is registered here; the bookkeeping is
...@@ -1120,6 +1126,9 @@ export function syncServerShellSnapshot(...@@ -1120,6 +1126,9 @@ export function syncServerShellSnapshot(
1120 snapshot: OrchestrationShellSnapshot,1126 snapshot: OrchestrationShellSnapshot,
1121 environmentId: EnvironmentId,1127 environmentId: EnvironmentId,
1122): AppState {1128): AppState {
1129 // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY):
1130 // Keep web-specific projection here only until the store can consume
1131 // createShellSnapshotManager or a shared adapter over its reducer.
1123 return commitEnvironmentState(1132 return commitEnvironmentState(
1124 state,1133 state,
1125 environmentId,1134 environmentId,
...@@ -1136,6 +1145,9 @@ export function syncServerThreadDetail(...@@ -1136,6 +1145,9 @@ export function syncServerThreadDetail(
1136 thread: OrchestrationThread,1145 thread: OrchestrationThread,
1137 environmentId: EnvironmentId,1146 environmentId: EnvironmentId,
1138): AppState {1147): AppState {
1148 // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY):
1149 // Keep web-specific projection here only until the store can consume
1150 // createThreadDetailManager or a shared adapter over its reducer.
1139 const environmentState = getStoredEnvironmentState(state, environmentId);1151 const environmentState = getStoredEnvironmentState(state, environmentId);
1140 const previousThread = getThreadFromEnvironmentState(environmentState, thread.id);1152 const previousThread = getThreadFromEnvironmentState(environmentState, thread.id);
1141 return commitEnvironmentState(1153 return commitEnvironmentState(
apps/web/src/terminalActivity.test.ts deleted-72
...@@ -1,72 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import type { TerminalEvent, TerminalSessionSnapshot } from "@t3tools/contracts";
4import { terminalRunningSubprocessFromEvent } from "./terminalActivity";
5
6const snapshot: TerminalSessionSnapshot = {
7 threadId: "thread-1",
8 terminalId: "default",
9 cwd: "/tmp",
10 worktreePath: null,
11 status: "running",
12 pid: 1234,
13 history: "",
14 exitCode: null,
15 exitSignal: null,
16 updatedAt: "2026-01-01T00:00:00.000Z",
17};
18
19function eventBase() {
20 return {
21 threadId: "thread-1",
22 terminalId: "default",
23 createdAt: "2026-01-01T00:00:00.000Z",
24 };
25}
26
27describe("terminalRunningSubprocessFromEvent", () => {
28 it("returns the subprocess flag for terminal activity events", () => {
29 const active = terminalRunningSubprocessFromEvent({
30 ...eventBase(),
31 type: "activity",
32 hasRunningSubprocess: true,
33 });
34 const idle = terminalRunningSubprocessFromEvent({
35 ...eventBase(),
36 type: "activity",
37 hasRunningSubprocess: false,
38 });
39
40 expect(active).toBe(true);
41 expect(idle).toBe(false);
42 });
43
44 it("clears running state when a terminal session starts/restarts/exits", () => {
45 const events: TerminalEvent[] = [
46 { ...eventBase(), type: "started", snapshot },
47 { ...eventBase(), type: "restarted", snapshot },
48 { ...eventBase(), type: "exited", exitCode: 0, exitSignal: null },
49 ];
50
51 for (const event of events) {
52 expect(terminalRunningSubprocessFromEvent(event)).toBe(false);
53 }
54 });
55
56 it("ignores non-activity terminal events", () => {
57 expect(
58 terminalRunningSubprocessFromEvent({
59 ...eventBase(),
60 type: "output",
61 data: "hello",
62 }),
63 ).toBeNull();
64 expect(
65 terminalRunningSubprocessFromEvent({
66 ...eventBase(),
67 type: "error",
68 message: "oops",
69 }),
70 ).toBeNull();
71 });
72});
apps/web/src/terminalActivity.ts deleted-14
...@@ -1,14 +0,0 @@
1import type { TerminalEvent } from "@t3tools/contracts";
2
3export function terminalRunningSubprocessFromEvent(event: TerminalEvent): boolean | null {
4 switch (event.type) {
5 case "activity":
6 return event.hasRunningSubprocess;
7 case "started":
8 case "restarted":
9 case "exited":
10 return false;
11 default:
12 return null;
13 }
14}
apps/web/src/terminalSessionState.ts created+77
...@@ -0,0 +1,77 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM,
4 EMPTY_TERMINAL_ID_LIST_ATOM,
5 EMPTY_TERMINAL_SESSION_ATOM,
6 createTerminalSessionManager,
7 getKnownTerminalSessionTarget,
8 getKnownTerminalSessionListFilter,
9 knownTerminalSessionsAtom,
10 runningTerminalIdsAtom,
11 terminalSessionStateAtom,
12 type KnownTerminalSession,
13 type TerminalSessionTarget,
14 type TerminalSessionState,
15} from "@t3tools/client-runtime";
16import type {
17 EnvironmentId,
18 TerminalAttachInput,
19 TerminalSessionSnapshot,
20 ThreadId,
21} from "@t3tools/contracts";
22
23import { appAtomRegistry } from "./rpc/atomRegistry";
24
25export const terminalSessionManager = createTerminalSessionManager({
26 getRegistry: () => appAtomRegistry,
27});
28
29export function subscribeTerminalMetadata(input: {
30 readonly environmentId: EnvironmentId;
31 readonly client: Parameters<typeof terminalSessionManager.subscribeMetadata>[0]["client"];
32}) {
33 return terminalSessionManager.subscribeMetadata(input);
34}
35
36export function attachTerminalSession(input: {
37 readonly environmentId: EnvironmentId;
38 readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"];
39 readonly terminal: TerminalAttachInput;
40 readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void;
41 readonly onEvent?: Parameters<typeof terminalSessionManager.attach>[0]["onEvent"];
42}) {
43 return terminalSessionManager.attach({
44 environmentId: input.environmentId,
45 client: input.client,
46 terminal: input.terminal,
47 ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}),
48 ...(input.onEvent ? { onEvent: input.onEvent } : {}),
49 });
50}
51
52export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState {
53 const target = getKnownTerminalSessionTarget(input);
54 return useAtomValue(
55 target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM,
56 );
57}
58
59export function useKnownTerminalSessions(input: {
60 readonly environmentId: EnvironmentId | null;
61 readonly threadId: ThreadId | null;
62}): ReadonlyArray<KnownTerminalSession> {
63 const filter = getKnownTerminalSessionListFilter(input);
64 return useAtomValue(
65 filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM,
66 );
67}
68
69export function useThreadRunningTerminalIds(input: {
70 readonly environmentId: EnvironmentId | null;
71 readonly threadId: ThreadId | null;
72}): ReadonlyArray<string> {
73 const filter = getKnownTerminalSessionListFilter(input);
74 return useAtomValue(
75 filter !== null ? runningTerminalIdsAtom(filter) : EMPTY_TERMINAL_ID_LIST_ATOM,
76 );
77}
apps/web/src/terminalStateStore.test.ts deleted-396
...@@ -1,396 +0,0 @@
1import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime";
2import { ThreadId, type TerminalEvent } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vitest";
4
5import {
6 migratePersistedTerminalStateStoreState,
7 selectTerminalEventEntries,
8 selectThreadTerminalState,
9 useTerminalStateStore,
10} from "./terminalStateStore";
11
12const THREAD_ID = ThreadId.make("thread-1");
13const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID);
14const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID);
15
16function makeTerminalEvent(
17 type: TerminalEvent["type"],
18 overrides: Partial<TerminalEvent> = {},
19): TerminalEvent {
20 const base = {
21 threadId: THREAD_ID,
22 terminalId: "default",
23 createdAt: "2026-04-02T20:00:00.000Z",
24 };
25
26 switch (type) {
27 case "output":
28 return { ...base, type, data: "hello\n", ...overrides } as TerminalEvent;
29 case "activity":
30 return { ...base, type, hasRunningSubprocess: true, ...overrides } as TerminalEvent;
31 case "error":
32 return { ...base, type, message: "boom", ...overrides } as TerminalEvent;
33 case "cleared":
34 return { ...base, type, ...overrides } as TerminalEvent;
35 case "exited":
36 return { ...base, type, exitCode: 0, exitSignal: null, ...overrides } as TerminalEvent;
37 case "started":
38 case "restarted":
39 return {
40 ...base,
41 type,
42 snapshot: {
43 threadId: THREAD_ID,
44 terminalId: "default",
45 cwd: "/tmp/workspace",
46 worktreePath: null,
47 status: "running",
48 pid: 123,
49 history: "",
50 exitCode: null,
51 exitSignal: null,
52 updatedAt: "2026-04-02T20:00:00.000Z",
53 },
54 ...overrides,
55 } as TerminalEvent;
56 }
57}
58
59describe("terminalStateStore actions", () => {
60 beforeEach(() => {
61 useTerminalStateStore.persist.clearStorage();
62 useTerminalStateStore.setState({
63 terminalStateByThreadKey: {},
64 terminalLaunchContextByThreadKey: {},
65 terminalEventEntriesByKey: {},
66 nextTerminalEventId: 1,
67 });
68 });
69
70 it("returns a closed default terminal state for unknown threads", () => {
71 const terminalState = selectThreadTerminalState(
72 useTerminalStateStore.getState().terminalStateByThreadKey,
73 THREAD_REF,
74 );
75 expect(terminalState).toEqual({
76 terminalOpen: false,
77 terminalHeight: 280,
78 terminalIds: ["default"],
79 runningTerminalIds: [],
80 activeTerminalId: "default",
81 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
82 activeTerminalGroupId: "group-default",
83 });
84 });
85
86 it("opens and splits terminals into the active group", () => {
87 const store = useTerminalStateStore.getState();
88 store.setTerminalOpen(THREAD_REF, true);
89 store.splitTerminal(THREAD_REF, "terminal-2");
90
91 const terminalState = selectThreadTerminalState(
92 useTerminalStateStore.getState().terminalStateByThreadKey,
93 THREAD_REF,
94 );
95 expect(terminalState.terminalOpen).toBe(true);
96 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
97 expect(terminalState.activeTerminalId).toBe("terminal-2");
98 expect(terminalState.terminalGroups).toEqual([
99 { id: "group-default", terminalIds: ["default", "terminal-2"] },
100 ]);
101 });
102
103 it("caps splits at four terminals per group", () => {
104 const store = useTerminalStateStore.getState();
105 store.splitTerminal(THREAD_REF, "terminal-2");
106 store.splitTerminal(THREAD_REF, "terminal-3");
107 store.splitTerminal(THREAD_REF, "terminal-4");
108 store.splitTerminal(THREAD_REF, "terminal-5");
109
110 const terminalState = selectThreadTerminalState(
111 useTerminalStateStore.getState().terminalStateByThreadKey,
112 THREAD_REF,
113 );
114 expect(terminalState.terminalIds).toEqual([
115 "default",
116 "terminal-2",
117 "terminal-3",
118 "terminal-4",
119 ]);
120 expect(terminalState.terminalGroups).toEqual([
121 { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] },
122 ]);
123 });
124
125 it("creates new terminals in a separate group", () => {
126 useTerminalStateStore.getState().newTerminal(THREAD_REF, "terminal-2");
127
128 const terminalState = selectThreadTerminalState(
129 useTerminalStateStore.getState().terminalStateByThreadKey,
130 THREAD_REF,
131 );
132 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
133 expect(terminalState.activeTerminalId).toBe("terminal-2");
134 expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2");
135 expect(terminalState.terminalGroups).toEqual([
136 { id: "group-default", terminalIds: ["default"] },
137 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
138 ]);
139 });
140
141 it("ensures unknown server terminals are registered, opened, and activated", () => {
142 const store = useTerminalStateStore.getState();
143 store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true });
144
145 const terminalState = selectThreadTerminalState(
146 useTerminalStateStore.getState().terminalStateByThreadKey,
147 THREAD_REF,
148 );
149 expect(terminalState.terminalOpen).toBe(true);
150 expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]);
151 expect(terminalState.activeTerminalId).toBe("setup-setup");
152 expect(terminalState.terminalGroups).toEqual([
153 { id: "group-default", terminalIds: ["default"] },
154 { id: "group-setup-setup", terminalIds: ["setup-setup"] },
155 ]);
156 });
157
158 it("keeps state isolated per environment when raw thread ids collide", () => {
159 const store = useTerminalStateStore.getState();
160 store.setTerminalOpen(THREAD_REF, true);
161 store.newTerminal(OTHER_THREAD_REF, "env-b-terminal");
162
163 expect(
164 selectThreadTerminalState(
165 useTerminalStateStore.getState().terminalStateByThreadKey,
166 THREAD_REF,
167 ).terminalOpen,
168 ).toBe(true);
169 expect(
170 selectThreadTerminalState(
171 useTerminalStateStore.getState().terminalStateByThreadKey,
172 OTHER_THREAD_REF,
173 ).terminalIds,
174 ).toEqual(["default", "env-b-terminal"]);
175 });
176
177 it("migrates v1 persisted terminal state using the stored version", () => {
178 const migrated = migratePersistedTerminalStateStoreState(
179 {
180 terminalStateByThreadKey: {
181 [scopedThreadKey(THREAD_REF)]: {
182 terminalOpen: true,
183 terminalHeight: 320,
184 terminalIds: ["default"],
185 runningTerminalIds: [],
186 activeTerminalId: "default",
187 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
188 activeTerminalGroupId: "group-default",
189 },
190 "legacy-thread-id": {
191 terminalOpen: true,
192 terminalHeight: 320,
193 terminalIds: ["default"],
194 runningTerminalIds: [],
195 activeTerminalId: "default",
196 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
197 activeTerminalGroupId: "group-default",
198 },
199 },
200 },
201 1,
202 );
203
204 expect(migrated).toEqual({
205 terminalStateByThreadKey: {
206 [scopedThreadKey(THREAD_REF)]: {
207 terminalOpen: true,
208 terminalHeight: 320,
209 terminalIds: ["default"],
210 runningTerminalIds: [],
211 activeTerminalId: "default",
212 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
213 activeTerminalGroupId: "group-default",
214 },
215 },
216 });
217 });
218
219 it("tracks and clears terminal subprocess activity", () => {
220 const store = useTerminalStateStore.getState();
221 store.splitTerminal(THREAD_REF, "terminal-2");
222 store.setTerminalActivity(THREAD_REF, "terminal-2", true);
223 expect(
224 selectThreadTerminalState(
225 useTerminalStateStore.getState().terminalStateByThreadKey,
226 THREAD_REF,
227 ).runningTerminalIds,
228 ).toEqual(["terminal-2"]);
229
230 store.setTerminalActivity(THREAD_REF, "terminal-2", false);
231 expect(
232 selectThreadTerminalState(
233 useTerminalStateStore.getState().terminalStateByThreadKey,
234 THREAD_REF,
235 ).runningTerminalIds,
236 ).toEqual([]);
237 });
238
239 it("resets to default and clears persisted entry when closing the last terminal", () => {
240 const store = useTerminalStateStore.getState();
241 store.closeTerminal(THREAD_REF, "default");
242
243 expect(
244 useTerminalStateStore.getState().terminalStateByThreadKey[scopedThreadKey(THREAD_REF)],
245 ).toBeUndefined();
246 expect(
247 selectThreadTerminalState(
248 useTerminalStateStore.getState().terminalStateByThreadKey,
249 THREAD_REF,
250 ).terminalIds,
251 ).toEqual(["default"]);
252 });
253
254 it("keeps a valid active terminal after closing an active split terminal", () => {
255 const store = useTerminalStateStore.getState();
256 store.splitTerminal(THREAD_REF, "terminal-2");
257 store.splitTerminal(THREAD_REF, "terminal-3");
258 store.closeTerminal(THREAD_REF, "terminal-3");
259
260 const terminalState = selectThreadTerminalState(
261 useTerminalStateStore.getState().terminalStateByThreadKey,
262 THREAD_REF,
263 );
264 expect(terminalState.activeTerminalId).toBe("terminal-2");
265 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
266 expect(terminalState.terminalGroups).toEqual([
267 { id: "group-default", terminalIds: ["default", "terminal-2"] },
268 ]);
269 });
270
271 it("buffers terminal events outside persisted terminal UI state", () => {
272 const store = useTerminalStateStore.getState();
273 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output"));
274 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("activity"));
275
276 const entries = selectTerminalEventEntries(
277 useTerminalStateStore.getState().terminalEventEntriesByKey,
278 THREAD_REF,
279 "default",
280 );
281
282 expect(entries).toHaveLength(2);
283 expect(entries.map((entry) => entry.id)).toEqual([1, 2]);
284 expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]);
285 });
286
287 it("applies started terminal events to terminal state, launch context, and event buffer", () => {
288 const store = useTerminalStateStore.getState();
289 store.applyTerminalEvent(
290 THREAD_REF,
291 makeTerminalEvent("started", {
292 terminalId: "setup-bootstrap",
293 snapshot: {
294 threadId: THREAD_ID,
295 terminalId: "setup-bootstrap",
296 cwd: "/tmp/worktree",
297 worktreePath: "/tmp/worktree",
298 status: "running",
299 pid: 123,
300 history: "",
301 exitCode: null,
302 exitSignal: null,
303 updatedAt: "2026-04-02T20:00:00.000Z",
304 },
305 }),
306 );
307
308 const terminalState = selectThreadTerminalState(
309 useTerminalStateStore.getState().terminalStateByThreadKey,
310 THREAD_REF,
311 );
312 const entries = selectTerminalEventEntries(
313 useTerminalStateStore.getState().terminalEventEntriesByKey,
314 THREAD_REF,
315 "setup-bootstrap",
316 );
317
318 expect(terminalState.terminalOpen).toBe(true);
319 expect(terminalState.activeTerminalId).toBe("setup-bootstrap");
320 expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]);
321 expect(
322 useTerminalStateStore.getState().terminalLaunchContextByThreadKey[
323 scopedThreadKey(THREAD_REF)
324 ],
325 ).toEqual({
326 cwd: "/tmp/worktree",
327 worktreePath: "/tmp/worktree",
328 });
329 expect(entries).toHaveLength(1);
330 expect(entries[0]?.event.type).toBe("started");
331 });
332
333 it("applies activity and exited terminal events to subprocess state while buffering events", () => {
334 const store = useTerminalStateStore.getState();
335 store.ensureTerminal(THREAD_REF, "terminal-2", { open: true, active: true });
336
337 store.applyTerminalEvent(
338 THREAD_REF,
339 makeTerminalEvent("activity", {
340 terminalId: "terminal-2",
341 hasRunningSubprocess: true,
342 }),
343 );
344 expect(
345 selectThreadTerminalState(
346 useTerminalStateStore.getState().terminalStateByThreadKey,
347 THREAD_REF,
348 ).runningTerminalIds,
349 ).toEqual(["terminal-2"]);
350
351 store.applyTerminalEvent(
352 THREAD_REF,
353 makeTerminalEvent("exited", {
354 terminalId: "terminal-2",
355 exitCode: 0,
356 exitSignal: null,
357 }),
358 );
359
360 const terminalState = selectThreadTerminalState(
361 useTerminalStateStore.getState().terminalStateByThreadKey,
362 THREAD_REF,
363 );
364 const entries = selectTerminalEventEntries(
365 useTerminalStateStore.getState().terminalEventEntriesByKey,
366 THREAD_REF,
367 "terminal-2",
368 );
369
370 expect(terminalState.runningTerminalIds).toEqual([]);
371 expect(entries.map((entry) => entry.event.type)).toEqual(["activity", "exited"]);
372 });
373
374 it("clears buffered terminal events when a thread terminal state is removed", () => {
375 const store = useTerminalStateStore.getState();
376 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output"));
377 store.removeTerminalState(THREAD_REF);
378
379 const entries = selectTerminalEventEntries(
380 useTerminalStateStore.getState().terminalEventEntriesByKey,
381 THREAD_REF,
382 "default",
383 );
384
385 expect(entries).toEqual([]);
386 });
387
388 it("is a no-op when clearing terminal state for a thread with no state or buffered events", () => {
389 const store = useTerminalStateStore.getState();
390 const before = useTerminalStateStore.getState();
391
392 store.clearTerminalState(THREAD_REF);
393
394 expect(useTerminalStateStore.getState()).toBe(before);
395 });
396});
apps/web/src/terminalStateStore.ts deleted-847
...@@ -1,847 +0,0 @@
1/**
2 * Single Zustand store for terminal UI state keyed by scoped thread identity.
3 *
4 * Terminal transition helpers are intentionally private to keep the public
5 * API constrained to store actions/selectors.
6 */
7
8import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";
9import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts";
10import { create } from "zustand";
11import { createJSONStorage, persist } from "zustand/middleware";
12import { resolveStorage } from "./lib/storage";
13import { terminalRunningSubprocessFromEvent } from "./terminalActivity";
14import {
15 DEFAULT_THREAD_TERMINAL_HEIGHT,
16 DEFAULT_THREAD_TERMINAL_ID,
17 MAX_TERMINALS_PER_GROUP,
18 type ThreadTerminalGroup,
19} from "./types";
20
21interface ThreadTerminalState {
22 terminalOpen: boolean;
23 terminalHeight: number;
24 terminalIds: string[];
25 runningTerminalIds: string[];
26 activeTerminalId: string;
27 terminalGroups: ThreadTerminalGroup[];
28 activeTerminalGroupId: string;
29}
30
31export interface ThreadTerminalLaunchContext {
32 cwd: string;
33 worktreePath: string | null;
34}
35
36export interface TerminalEventEntry {
37 id: number;
38 event: TerminalEvent;
39}
40
41const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
42const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = [];
43const MAX_TERMINAL_EVENT_BUFFER = 200;
44
45interface PersistedTerminalStateStoreState {
46 terminalStateByThreadKey?: Record<string, ThreadTerminalState>;
47}
48
49export function migratePersistedTerminalStateStoreState(
50 persistedState: unknown,
51 version: number,
52): PersistedTerminalStateStoreState {
53 if (version === 1 && persistedState && typeof persistedState === "object") {
54 const candidate = persistedState as PersistedTerminalStateStoreState;
55 const nextTerminalStateByThreadKey = Object.fromEntries(
56 Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) =>
57 parseScopedThreadKey(threadKey),
58 ),
59 );
60 return { terminalStateByThreadKey: nextTerminalStateByThreadKey };
61 }
62 return { terminalStateByThreadKey: {} };
63}
64
65function createTerminalStateStorage() {
66 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
67}
68
69function normalizeTerminalIds(terminalIds: string[]): string[] {
70 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
71 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
72}
73
74function normalizeRunningTerminalIds(
75 runningTerminalIds: string[],
76 terminalIds: string[],
77): string[] {
78 if (runningTerminalIds.length === 0) return [];
79 const validTerminalIdSet = new Set(terminalIds);
80 return [...new Set(runningTerminalIds)]
81 .map((id) => id.trim())
82 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
83}
84
85function fallbackGroupId(terminalId: string): string {
86 return `group-${terminalId}`;
87}
88
89function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {
90 let candidate = baseId;
91 let index = 2;
92 while (usedGroupIds.has(candidate)) {
93 candidate = `${baseId}-${index}`;
94 index += 1;
95 }
96 usedGroupIds.add(candidate);
97 return candidate;
98}
99
100function findGroupIndexByTerminalId(
101 terminalGroups: ThreadTerminalGroup[],
102 terminalId: string,
103): number {
104 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
105}
106
107function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
108 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
109}
110
111function normalizeTerminalGroups(
112 terminalGroups: ThreadTerminalGroup[],
113 terminalIds: string[],
114): ThreadTerminalGroup[] {
115 const validTerminalIdSet = new Set(terminalIds);
116 const assignedTerminalIds = new Set<string>();
117 const nextGroups: ThreadTerminalGroup[] = [];
118 const usedGroupIds = new Set<string>();
119
120 for (const group of terminalGroups) {
121 const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {
122 if (!validTerminalIdSet.has(terminalId)) return false;
123 if (assignedTerminalIds.has(terminalId)) return false;
124 return true;
125 });
126 if (groupTerminalIds.length === 0) continue;
127 for (const terminalId of groupTerminalIds) {
128 assignedTerminalIds.add(terminalId);
129 }
130 const baseGroupId =
131 group.id.trim().length > 0
132 ? group.id.trim()
133 : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
134 nextGroups.push({
135 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
136 terminalIds: groupTerminalIds,
137 });
138 }
139
140 for (const terminalId of terminalIds) {
141 if (assignedTerminalIds.has(terminalId)) continue;
142 nextGroups.push({
143 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
144 terminalIds: [terminalId],
145 });
146 }
147
148 if (nextGroups.length === 0) {
149 return [
150 {
151 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
152 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
153 },
154 ];
155 }
156
157 return nextGroups;
158}
159
160function arraysEqual(a: string[], b: string[]): boolean {
161 if (a.length !== b.length) return false;
162 for (let index = 0; index < a.length; index += 1) {
163 if (a[index] !== b[index]) return false;
164 }
165 return true;
166}
167
168function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean {
169 if (left.length !== right.length) return false;
170 for (let index = 0; index < left.length; index += 1) {
171 const leftGroup = left[index];
172 const rightGroup = right[index];
173 if (!leftGroup || !rightGroup) return false;
174 if (leftGroup.id !== rightGroup.id) return false;
175 if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;
176 }
177 return true;
178}
179
180function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean {
181 return (
182 left.terminalOpen === right.terminalOpen &&
183 left.terminalHeight === right.terminalHeight &&
184 left.activeTerminalId === right.activeTerminalId &&
185 left.activeTerminalGroupId === right.activeTerminalGroupId &&
186 arraysEqual(left.terminalIds, right.terminalIds) &&
187 arraysEqual(left.runningTerminalIds, right.runningTerminalIds) &&
188 terminalGroupsEqual(left.terminalGroups, right.terminalGroups)
189 );
190}
191
192const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({
193 terminalOpen: false,
194 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
195 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
196 runningTerminalIds: [],
197 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
198 terminalGroups: [
199 {
200 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
201 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
202 },
203 ],
204 activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
205});
206
207function createDefaultThreadTerminalState(): ThreadTerminalState {
208 return {
209 ...DEFAULT_THREAD_TERMINAL_STATE,
210 terminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.terminalIds],
211 runningTerminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.runningTerminalIds],
212 terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_STATE.terminalGroups),
213 };
214}
215
216function getDefaultThreadTerminalState(): ThreadTerminalState {
217 return DEFAULT_THREAD_TERMINAL_STATE;
218}
219
220function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState {
221 const terminalIds = normalizeTerminalIds(state.terminalIds);
222 const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
223 const runningTerminalIds = normalizeRunningTerminalIds(state.runningTerminalIds, nextTerminalIds);
224 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
225 ? state.activeTerminalId
226 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
227 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
228 const activeGroupIdFromState = terminalGroups.some(
229 (group) => group.id === state.activeTerminalGroupId,
230 )
231 ? state.activeTerminalGroupId
232 : null;
233 const activeGroupIdFromTerminal =
234 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
235
236 const normalized: ThreadTerminalState = {
237 terminalOpen: state.terminalOpen,
238 terminalHeight:
239 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
240 ? state.terminalHeight
241 : DEFAULT_THREAD_TERMINAL_HEIGHT,
242 terminalIds: nextTerminalIds,
243 runningTerminalIds,
244 activeTerminalId,
245 terminalGroups,
246 activeTerminalGroupId:
247 activeGroupIdFromState ??
248 activeGroupIdFromTerminal ??
249 terminalGroups[0]?.id ??
250 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
251 };
252 return threadTerminalStateEqual(state, normalized) ? state : normalized;
253}
254
255function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean {
256 const normalized = normalizeThreadTerminalState(state);
257 return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE);
258}
259
260function isValidTerminalId(terminalId: string): boolean {
261 return terminalId.trim().length > 0;
262}
263
264function terminalThreadKey(threadRef: ScopedThreadRef): string {
265 return scopedThreadKey(threadRef);
266}
267
268function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string {
269 return `${terminalThreadKey(threadRef)}\u0000${terminalId}`;
270}
271
272function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
273 return groups.map((group) => ({
274 id: group.id,
275 terminalIds: [...group.terminalIds],
276 }));
277}
278
279function appendTerminalEventEntry(
280 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,
281 nextTerminalEventId: number,
282 threadRef: ScopedThreadRef,
283 event: TerminalEvent,
284) {
285 const key = terminalEventBufferKey(threadRef, event.terminalId);
286 const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES;
287 const nextEntry: TerminalEventEntry = {
288 id: nextTerminalEventId,
289 event,
290 };
291 const nextEntries =
292 currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER
293 ? [...currentEntries.slice(1), nextEntry]
294 : [...currentEntries, nextEntry];
295
296 return {
297 terminalEventEntriesByKey: {
298 ...terminalEventEntriesByKey,
299 [key]: nextEntries,
300 },
301 nextTerminalEventId: nextTerminalEventId + 1,
302 };
303}
304
305function launchContextFromStartEvent(
306 event: Extract<TerminalEvent, { type: "started" | "restarted" }>,
307): ThreadTerminalLaunchContext {
308 return {
309 cwd: event.snapshot.cwd,
310 worktreePath: event.snapshot.worktreePath,
311 };
312}
313
314function upsertTerminalIntoGroups(
315 state: ThreadTerminalState,
316 terminalId: string,
317 mode: "split" | "new",
318): ThreadTerminalState {
319 const normalized = normalizeThreadTerminalState(state);
320 if (!isValidTerminalId(terminalId)) {
321 return normalized;
322 }
323
324 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
325 const terminalIds = isNewTerminal
326 ? [...normalized.terminalIds, terminalId]
327 : normalized.terminalIds;
328 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
329
330 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
331 if (existingGroupIndex >= 0) {
332 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
333 existingGroupIndex
334 ]!.terminalIds.filter((id) => id !== terminalId);
335 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
336 terminalGroups.splice(existingGroupIndex, 1);
337 }
338 }
339
340 if (mode === "new") {
341 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
342 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
343 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
344 return normalizeThreadTerminalState({
345 ...normalized,
346 terminalOpen: true,
347 terminalIds,
348 activeTerminalId: terminalId,
349 terminalGroups,
350 activeTerminalGroupId: nextGroupId,
351 });
352 }
353
354 let activeGroupIndex = terminalGroups.findIndex(
355 (group) => group.id === normalized.activeTerminalGroupId,
356 );
357 if (activeGroupIndex < 0) {
358 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
359 }
360 if (activeGroupIndex < 0) {
361 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
362 const nextGroupId = assignUniqueGroupId(
363 fallbackGroupId(normalized.activeTerminalId),
364 usedGroupIds,
365 );
366 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
367 activeGroupIndex = terminalGroups.length - 1;
368 }
369
370 const destinationGroup = terminalGroups[activeGroupIndex];
371 if (!destinationGroup) {
372 return normalized;
373 }
374
375 if (
376 isNewTerminal &&
377 !destinationGroup.terminalIds.includes(terminalId) &&
378 destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP
379 ) {
380 return normalized;
381 }
382
383 if (!destinationGroup.terminalIds.includes(terminalId)) {
384 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
385 if (anchorIndex >= 0) {
386 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
387 } else {
388 destinationGroup.terminalIds.push(terminalId);
389 }
390 }
391
392 return normalizeThreadTerminalState({
393 ...normalized,
394 terminalOpen: true,
395 terminalIds,
396 activeTerminalId: terminalId,
397 terminalGroups,
398 activeTerminalGroupId: destinationGroup.id,
399 });
400}
401
402function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState {
403 const normalized = normalizeThreadTerminalState(state);
404 if (normalized.terminalOpen === open) return normalized;
405 return { ...normalized, terminalOpen: open };
406}
407
408function setThreadTerminalHeight(state: ThreadTerminalState, height: number): ThreadTerminalState {
409 const normalized = normalizeThreadTerminalState(state);
410 if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) {
411 return normalized;
412 }
413 return { ...normalized, terminalHeight: height };
414}
415
416function splitThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {
417 return upsertTerminalIntoGroups(state, terminalId, "split");
418}
419
420function newThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {
421 return upsertTerminalIntoGroups(state, terminalId, "new");
422}
423
424function setThreadActiveTerminal(
425 state: ThreadTerminalState,
426 terminalId: string,
427): ThreadTerminalState {
428 const normalized = normalizeThreadTerminalState(state);
429 if (!normalized.terminalIds.includes(terminalId)) {
430 return normalized;
431 }
432 const activeTerminalGroupId =
433 normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ??
434 normalized.activeTerminalGroupId;
435 if (
436 normalized.activeTerminalId === terminalId &&
437 normalized.activeTerminalGroupId === activeTerminalGroupId
438 ) {
439 return normalized;
440 }
441 return {
442 ...normalized,
443 activeTerminalId: terminalId,
444 activeTerminalGroupId,
445 };
446}
447
448function closeThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {
449 const normalized = normalizeThreadTerminalState(state);
450 if (!normalized.terminalIds.includes(terminalId)) {
451 return normalized;
452 }
453
454 const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId);
455 if (remainingTerminalIds.length === 0) {
456 return createDefaultThreadTerminalState();
457 }
458
459 const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId);
460 const nextActiveTerminalId =
461 normalized.activeTerminalId === terminalId
462 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
463 remainingTerminalIds[0] ??
464 DEFAULT_THREAD_TERMINAL_ID)
465 : normalized.activeTerminalId;
466
467 const terminalGroups = normalized.terminalGroups
468 .map((group) => ({
469 ...group,
470 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
471 }))
472 .filter((group) => group.terminalIds.length > 0);
473
474 const nextActiveTerminalGroupId =
475 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??
476 terminalGroups[0]?.id ??
477 fallbackGroupId(nextActiveTerminalId);
478
479 return normalizeThreadTerminalState({
480 terminalOpen: normalized.terminalOpen,
481 terminalHeight: normalized.terminalHeight,
482 terminalIds: remainingTerminalIds,
483 runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId),
484 activeTerminalId: nextActiveTerminalId,
485 terminalGroups,
486 activeTerminalGroupId: nextActiveTerminalGroupId,
487 });
488}
489
490function setThreadTerminalActivity(
491 state: ThreadTerminalState,
492 terminalId: string,
493 hasRunningSubprocess: boolean,
494): ThreadTerminalState {
495 const normalized = normalizeThreadTerminalState(state);
496 if (!normalized.terminalIds.includes(terminalId)) {
497 return normalized;
498 }
499 const alreadyRunning = normalized.runningTerminalIds.includes(terminalId);
500 if (hasRunningSubprocess === alreadyRunning) {
501 return normalized;
502 }
503 const runningTerminalIds = new Set(normalized.runningTerminalIds);
504 if (hasRunningSubprocess) {
505 runningTerminalIds.add(terminalId);
506 } else {
507 runningTerminalIds.delete(terminalId);
508 }
509 return { ...normalized, runningTerminalIds: [...runningTerminalIds] };
510}
511
512export function selectThreadTerminalState(
513 terminalStateByThreadKey: Record<string, ThreadTerminalState>,
514 threadRef: ScopedThreadRef | null | undefined,
515): ThreadTerminalState {
516 if (!threadRef || threadRef.threadId.length === 0) {
517 return getDefaultThreadTerminalState();
518 }
519 return terminalStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalState();
520}
521
522function updateTerminalStateByThreadKey(
523 terminalStateByThreadKey: Record<string, ThreadTerminalState>,
524 threadRef: ScopedThreadRef,
525 updater: (state: ThreadTerminalState) => ThreadTerminalState,
526): Record<string, ThreadTerminalState> {
527 if (threadRef.threadId.length === 0) {
528 return terminalStateByThreadKey;
529 }
530
531 const threadKey = terminalThreadKey(threadRef);
532 const current = selectThreadTerminalState(terminalStateByThreadKey, threadRef);
533 const next = updater(current);
534 if (next === current) {
535 return terminalStateByThreadKey;
536 }
537
538 if (isDefaultThreadTerminalState(next)) {
539 if (terminalStateByThreadKey[threadKey] === undefined) {
540 return terminalStateByThreadKey;
541 }
542 const { [threadKey]: _removed, ...rest } = terminalStateByThreadKey;
543 return rest;
544 }
545
546 return {
547 ...terminalStateByThreadKey,
548 [threadKey]: next,
549 };
550}
551
552export function selectTerminalEventEntries(
553 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,
554 threadRef: ScopedThreadRef | null | undefined,
555 terminalId: string,
556): ReadonlyArray<TerminalEventEntry> {
557 if (!threadRef || threadRef.threadId.length === 0 || terminalId.trim().length === 0) {
558 return EMPTY_TERMINAL_EVENT_ENTRIES;
559 }
560 return (
561 terminalEventEntriesByKey[terminalEventBufferKey(threadRef, terminalId)] ??
562 EMPTY_TERMINAL_EVENT_ENTRIES
563 );
564}
565
566interface TerminalStateStoreState {
567 terminalStateByThreadKey: Record<string, ThreadTerminalState>;
568 terminalLaunchContextByThreadKey: Record<string, ThreadTerminalLaunchContext>;
569 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>;
570 nextTerminalEventId: number;
571 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
572 setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void;
573 splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
574 newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
575 ensureTerminal: (
576 threadRef: ScopedThreadRef,
577 terminalId: string,
578 options?: { open?: boolean; active?: boolean },
579 ) => void;
580 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
581 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
582 setTerminalLaunchContext: (
583 threadRef: ScopedThreadRef,
584 context: ThreadTerminalLaunchContext,
585 ) => void;
586 clearTerminalLaunchContext: (threadRef: ScopedThreadRef) => void;
587 setTerminalActivity: (
588 threadRef: ScopedThreadRef,
589 terminalId: string,
590 hasRunningSubprocess: boolean,
591 ) => void;
592 recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void;
593 applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void;
594 clearTerminalState: (threadRef: ScopedThreadRef) => void;
595 removeTerminalState: (threadRef: ScopedThreadRef) => void;
596 removeOrphanedTerminalStates: (activeThreadKeys: Set<string>) => void;
597}
598
599export const useTerminalStateStore = create<TerminalStateStoreState>()(
600 persist(
601 (set) => {
602 const updateTerminal = (
603 threadRef: ScopedThreadRef,
604 updater: (state: ThreadTerminalState) => ThreadTerminalState,
605 ) => {
606 set((state) => {
607 const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
608 state.terminalStateByThreadKey,
609 threadRef,
610 updater,
611 );
612 if (nextTerminalStateByThreadKey === state.terminalStateByThreadKey) {
613 return state;
614 }
615 return {
616 terminalStateByThreadKey: nextTerminalStateByThreadKey,
617 };
618 });
619 };
620
621 return {
622 terminalStateByThreadKey: {},
623 terminalLaunchContextByThreadKey: {},
624 terminalEventEntriesByKey: {},
625 nextTerminalEventId: 1,
626 setTerminalOpen: (threadRef, open) =>
627 updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)),
628 setTerminalHeight: (threadRef, height) =>
629 updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)),
630 splitTerminal: (threadRef, terminalId) =>
631 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)),
632 newTerminal: (threadRef, terminalId) =>
633 updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)),
634 ensureTerminal: (threadRef, terminalId, options) =>
635 updateTerminal(threadRef, (state) => {
636 let nextState = state;
637 if (!state.terminalIds.includes(terminalId)) {
638 nextState = newThreadTerminal(nextState, terminalId);
639 }
640 if (options?.active === false) {
641 nextState = {
642 ...nextState,
643 activeTerminalId: state.activeTerminalId,
644 activeTerminalGroupId: state.activeTerminalGroupId,
645 };
646 }
647 if (options?.active ?? true) {
648 nextState = setThreadActiveTerminal(nextState, terminalId);
649 }
650 if (options?.open) {
651 nextState = setThreadTerminalOpen(nextState, true);
652 }
653 return normalizeThreadTerminalState(nextState);
654 }),
655 setActiveTerminal: (threadRef, terminalId) =>
656 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),
657 closeTerminal: (threadRef, terminalId) =>
658 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)),
659 setTerminalLaunchContext: (threadRef, context) =>
660 set((state) => ({
661 terminalLaunchContextByThreadKey: {
662 ...state.terminalLaunchContextByThreadKey,
663 [terminalThreadKey(threadRef)]: context,
664 },
665 })),
666 clearTerminalLaunchContext: (threadRef) =>
667 set((state) => {
668 const threadKey = terminalThreadKey(threadRef);
669 if (!state.terminalLaunchContextByThreadKey[threadKey]) {
670 return state;
671 }
672 const { [threadKey]: _removed, ...rest } = state.terminalLaunchContextByThreadKey;
673 return { terminalLaunchContextByThreadKey: rest };
674 }),
675 setTerminalActivity: (threadRef, terminalId, hasRunningSubprocess) =>
676 updateTerminal(threadRef, (state) =>
677 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
678 ),
679 recordTerminalEvent: (threadRef, event) =>
680 set((state) =>
681 appendTerminalEventEntry(
682 state.terminalEventEntriesByKey,
683 state.nextTerminalEventId,
684 threadRef,
685 event,
686 ),
687 ),
688 applyTerminalEvent: (threadRef, event) =>
689 set((state) => {
690 const threadKey = terminalThreadKey(threadRef);
691 let nextTerminalStateByThreadKey = state.terminalStateByThreadKey;
692 let nextTerminalLaunchContextByThreadKey = state.terminalLaunchContextByThreadKey;
693
694 if (event.type === "started" || event.type === "restarted") {
695 nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
696 nextTerminalStateByThreadKey,
697 threadRef,
698 (current) => {
699 let nextState = current;
700 if (!current.terminalIds.includes(event.terminalId)) {
701 nextState = newThreadTerminal(nextState, event.terminalId);
702 }
703 nextState = setThreadActiveTerminal(nextState, event.terminalId);
704 nextState = setThreadTerminalOpen(nextState, true);
705 return normalizeThreadTerminalState(nextState);
706 },
707 );
708 nextTerminalLaunchContextByThreadKey = {
709 ...nextTerminalLaunchContextByThreadKey,
710 [threadKey]: launchContextFromStartEvent(event),
711 };
712 }
713
714 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
715 if (hasRunningSubprocess !== null) {
716 nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
717 nextTerminalStateByThreadKey,
718 threadRef,
719 (current) =>
720 setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess),
721 );
722 }
723
724 const nextEventState = appendTerminalEventEntry(
725 state.terminalEventEntriesByKey,
726 state.nextTerminalEventId,
727 threadRef,
728 event,
729 );
730
731 return {
732 terminalStateByThreadKey: nextTerminalStateByThreadKey,
733 terminalLaunchContextByThreadKey: nextTerminalLaunchContextByThreadKey,
734 ...nextEventState,
735 };
736 }),
737 clearTerminalState: (threadRef) =>
738 set((state) => {
739 const threadKey = terminalThreadKey(threadRef);
740 const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
741 state.terminalStateByThreadKey,
742 threadRef,
743 () => createDefaultThreadTerminalState(),
744 );
745 const hadLaunchContext =
746 state.terminalLaunchContextByThreadKey[threadKey] !== undefined;
747 const { [threadKey]: _removed, ...remainingLaunchContexts } =
748 state.terminalLaunchContextByThreadKey;
749 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
750 let removedEventEntries = false;
751 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
752 if (key.startsWith(`${threadKey}\u0000`)) {
753 delete nextTerminalEventEntriesByKey[key];
754 removedEventEntries = true;
755 }
756 }
757 if (
758 nextTerminalStateByThreadKey === state.terminalStateByThreadKey &&
759 !hadLaunchContext &&
760 !removedEventEntries
761 ) {
762 return state;
763 }
764 return {
765 terminalStateByThreadKey: nextTerminalStateByThreadKey,
766 terminalLaunchContextByThreadKey: remainingLaunchContexts,
767 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
768 };
769 }),
770 removeTerminalState: (threadRef) =>
771 set((state) => {
772 const threadKey = terminalThreadKey(threadRef);
773 const hadTerminalState = state.terminalStateByThreadKey[threadKey] !== undefined;
774 const hadLaunchContext =
775 state.terminalLaunchContextByThreadKey[threadKey] !== undefined;
776 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
777 let removedEventEntries = false;
778 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
779 if (key.startsWith(`${threadKey}\u0000`)) {
780 delete nextTerminalEventEntriesByKey[key];
781 removedEventEntries = true;
782 }
783 }
784 if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) {
785 return state;
786 }
787 const nextTerminalStateByThreadKey = { ...state.terminalStateByThreadKey };
788 delete nextTerminalStateByThreadKey[threadKey];
789 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey };
790 delete nextLaunchContexts[threadKey];
791 return {
792 terminalStateByThreadKey: nextTerminalStateByThreadKey,
793 terminalLaunchContextByThreadKey: nextLaunchContexts,
794 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
795 };
796 }),
797 removeOrphanedTerminalStates: (activeThreadKeys) =>
798 set((state) => {
799 const orphanedIds = Object.keys(state.terminalStateByThreadKey).filter(
800 (key) => !activeThreadKeys.has(key),
801 );
802 const orphanedLaunchContextIds = Object.keys(
803 state.terminalLaunchContextByThreadKey,
804 ).filter((key) => !activeThreadKeys.has(key));
805 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
806 let removedEventEntries = false;
807 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
808 const [threadKey] = key.split("\u0000");
809 if (threadKey && !activeThreadKeys.has(threadKey)) {
810 delete nextTerminalEventEntriesByKey[key];
811 removedEventEntries = true;
812 }
813 }
814 if (
815 orphanedIds.length === 0 &&
816 orphanedLaunchContextIds.length === 0 &&
817 !removedEventEntries
818 ) {
819 return state;
820 }
821 const next = { ...state.terminalStateByThreadKey };
822 for (const id of orphanedIds) {
823 delete next[id];
824 }
825 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey };
826 for (const id of orphanedLaunchContextIds) {
827 delete nextLaunchContexts[id];
828 }
829 return {
830 terminalStateByThreadKey: next,
831 terminalLaunchContextByThreadKey: nextLaunchContexts,
832 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
833 };
834 }),
835 };
836 },
837 {
838 name: TERMINAL_STATE_STORAGE_KEY,
839 version: 2,
840 storage: createJSONStorage(createTerminalStateStorage),
841 migrate: migratePersistedTerminalStateStoreState,
842 partialize: (state) => ({
843 terminalStateByThreadKey: state.terminalStateByThreadKey,
844 }),
845 },
846 ),
847);
apps/web/src/terminalUiStateStore.test.ts created+254
...@@ -0,0 +1,254 @@
1import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime";
2import { ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vitest";
4
5import {
6 migratePersistedTerminalUiStateStoreState,
7 selectThreadTerminalUiState,
8 useTerminalUiStateStore,
9} from "./terminalUiStateStore";
10import { DEFAULT_THREAD_TERMINAL_ID } from "./types";
11
12const THREAD_ID = ThreadId.make("thread-1");
13const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID);
14const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID);
15
16describe("terminalUiStateStore actions", () => {
17 beforeEach(() => {
18 useTerminalUiStateStore.persist.clearStorage();
19 useTerminalUiStateStore.setState({
20 terminalUiStateByThreadKey: {},
21 });
22 });
23
24 it("returns an empty default terminal UI state for unknown threads", () => {
25 const terminalUiState = selectThreadTerminalUiState(
26 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
27 THREAD_REF,
28 );
29 expect(terminalUiState).toEqual({
30 terminalOpen: false,
31 terminalHeight: 280,
32 terminalIds: [],
33 activeTerminalId: "",
34 terminalGroups: [],
35 activeTerminalGroupId: "",
36 });
37 });
38
39 it("opens and splits terminals into the active group", () => {
40 const store = useTerminalUiStateStore.getState();
41 store.setTerminalOpen(THREAD_REF, true);
42 store.splitTerminal(THREAD_REF, "terminal-2");
43
44 const terminalUiState = selectThreadTerminalUiState(
45 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
46 THREAD_REF,
47 );
48 expect(terminalUiState.terminalOpen).toBe(true);
49 expect(terminalUiState.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "terminal-2"]);
50 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
51 expect(terminalUiState.terminalGroups).toEqual([
52 {
53 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
54 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "terminal-2"],
55 },
56 ]);
57 });
58
59 it("materializes the default terminal when opening an empty drawer", () => {
60 useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true);
61
62 const terminalUiState = selectThreadTerminalUiState(
63 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
64 THREAD_REF,
65 );
66 expect(terminalUiState).toEqual({
67 terminalOpen: true,
68 terminalHeight: 280,
69 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
70 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
71 terminalGroups: [
72 {
73 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
74 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
75 },
76 ],
77 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
78 });
79 });
80
81 it("caps splits at four terminals per group", () => {
82 const store = useTerminalUiStateStore.getState();
83 store.splitTerminal(THREAD_REF, "terminal-2");
84 store.splitTerminal(THREAD_REF, "terminal-3");
85 store.splitTerminal(THREAD_REF, "terminal-4");
86 store.splitTerminal(THREAD_REF, "terminal-5");
87 store.splitTerminal(THREAD_REF, "terminal-6");
88
89 const terminalUiState = selectThreadTerminalUiState(
90 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
91 THREAD_REF,
92 );
93 expect(terminalUiState.terminalIds).toEqual([
94 "terminal-2",
95 "terminal-3",
96 "terminal-4",
97 "terminal-5",
98 ]);
99 expect(terminalUiState.terminalGroups).toEqual([
100 {
101 id: "group-terminal-2",
102 terminalIds: ["terminal-2", "terminal-3", "terminal-4", "terminal-5"],
103 },
104 ]);
105 });
106
107 it("creates new terminals in a separate group", () => {
108 useTerminalUiStateStore.getState().newTerminal(THREAD_REF, "terminal-2");
109
110 const terminalUiState = selectThreadTerminalUiState(
111 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
112 THREAD_REF,
113 );
114 expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
115 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
116 expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2");
117 expect(terminalUiState.terminalGroups).toEqual([
118 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
119 ]);
120 });
121
122 it("ensures unknown server terminals are registered, opened, and activated", () => {
123 const store = useTerminalUiStateStore.getState();
124 store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true });
125
126 const terminalUiState = selectThreadTerminalUiState(
127 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
128 THREAD_REF,
129 );
130 expect(terminalUiState.terminalOpen).toBe(true);
131 expect(terminalUiState.terminalIds).toEqual(["setup-setup"]);
132 expect(terminalUiState.activeTerminalId).toBe("setup-setup");
133 expect(terminalUiState.terminalGroups).toEqual([
134 { id: "group-setup-setup", terminalIds: ["setup-setup"] },
135 ]);
136 });
137
138 it("keeps state isolated per environment when raw thread ids collide", () => {
139 const store = useTerminalUiStateStore.getState();
140 store.setTerminalOpen(THREAD_REF, true);
141 store.newTerminal(OTHER_THREAD_REF, "env-b-terminal");
142
143 expect(
144 selectThreadTerminalUiState(
145 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
146 THREAD_REF,
147 ).terminalOpen,
148 ).toBe(true);
149 expect(
150 selectThreadTerminalUiState(
151 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
152 OTHER_THREAD_REF,
153 ).terminalIds,
154 ).toEqual(["env-b-terminal"]);
155 });
156
157 it("drops persisted entries whose thread keys are not valid scoped keys", () => {
158 const migrated = migratePersistedTerminalUiStateStoreState(
159 {
160 terminalStateByThreadKey: {
161 [scopedThreadKey(THREAD_REF)]: {
162 terminalOpen: true,
163 terminalHeight: 320,
164 terminalIds: ["term-1"],
165 activeTerminalId: "term-1",
166 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
167 activeTerminalGroupId: "group-term-1",
168 },
169 "legacy-thread-id": {
170 terminalOpen: true,
171 terminalHeight: 320,
172 terminalIds: ["term-1"],
173 activeTerminalId: "term-1",
174 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
175 activeTerminalGroupId: "group-term-1",
176 },
177 },
178 },
179 2,
180 );
181
182 expect(migrated).toEqual({
183 terminalUiStateByThreadKey: {
184 [scopedThreadKey(THREAD_REF)]: {
185 terminalOpen: true,
186 terminalHeight: 320,
187 terminalIds: ["term-1"],
188 activeTerminalId: "term-1",
189 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
190 activeTerminalGroupId: "group-term-1",
191 },
192 },
193 });
194 });
195
196 it("resets to default and clears persisted entry when closing the last terminal", () => {
197 const store = useTerminalUiStateStore.getState();
198 store.newTerminal(THREAD_REF, "terminal-only");
199 store.closeTerminal(THREAD_REF, "terminal-only");
200
201 expect(
202 useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)],
203 ).toBeUndefined();
204 expect(
205 selectThreadTerminalUiState(
206 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
207 THREAD_REF,
208 ).terminalIds,
209 ).toEqual([]);
210 });
211
212 it("keeps a valid active terminal after closing an active split terminal", () => {
213 const store = useTerminalUiStateStore.getState();
214 store.splitTerminal(THREAD_REF, "terminal-2");
215 store.splitTerminal(THREAD_REF, "terminal-3");
216 store.closeTerminal(THREAD_REF, "terminal-3");
217
218 const terminalUiState = selectThreadTerminalUiState(
219 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
220 THREAD_REF,
221 );
222 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
223 expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
224 expect(terminalUiState.terminalGroups).toEqual([
225 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
226 ]);
227 });
228
229 it("reconciles terminal ids from an external ordered list", () => {
230 const store = useTerminalUiStateStore.getState();
231 store.setTerminalOpen(THREAD_REF, true);
232 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);
233
234 const terminalUiState = selectThreadTerminalUiState(
235 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
236 THREAD_REF,
237 );
238 expect(terminalUiState.terminalIds).toEqual(["term-a", "term-b"]);
239 expect(terminalUiState.activeTerminalId).toBe("term-a");
240 expect(terminalUiState.terminalGroups).toEqual([
241 { id: "group-term-a", terminalIds: ["term-a"] },
242 { id: "group-term-b", terminalIds: ["term-b"] },
243 ]);
244 });
245
246 it("is a no-op when clearing terminal UI state for a thread with no state", () => {
247 const store = useTerminalUiStateStore.getState();
248 const before = useTerminalUiStateStore.getState();
249
250 store.clearTerminalUiState(THREAD_REF);
251
252 expect(useTerminalUiStateStore.getState()).toBe(before);
253 });
254});
apps/web/src/terminalUiStateStore.ts created+641
...@@ -0,0 +1,641 @@
1/**
2 * Single Zustand store for terminal UI state keyed by scoped thread identity.
3 *
4 * Terminal UI transition helpers are intentionally private to keep the public
5 * API constrained to store actions/selectors.
6 */
7
8import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";
9import { type ScopedThreadRef } from "@t3tools/contracts";
10import { create } from "zustand";
11import { createJSONStorage, persist } from "zustand/middleware";
12import { resolveStorage } from "./lib/storage";
13import {
14 DEFAULT_THREAD_TERMINAL_HEIGHT,
15 DEFAULT_THREAD_TERMINAL_ID,
16 MAX_TERMINALS_PER_GROUP,
17 type ThreadTerminalGroup,
18} from "./types";
19
20interface ThreadTerminalUiState {
21 terminalOpen: boolean;
22 terminalHeight: number;
23 terminalIds: string[];
24 activeTerminalId: string;
25 terminalGroups: ThreadTerminalGroup[];
26 activeTerminalGroupId: string;
27}
28
29// Keep the old storage key so existing drawer layout preferences migrate.
30const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
31
32interface PersistedTerminalUiStateStoreState {
33 terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>;
34 terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>;
35}
36
37export function migratePersistedTerminalUiStateStoreState(
38 persistedState: unknown,
39 _version: number,
40): PersistedTerminalUiStateStoreState {
41 if (!persistedState || typeof persistedState !== "object") {
42 return { terminalUiStateByThreadKey: {} };
43 }
44
45 const candidate = persistedState as PersistedTerminalUiStateStoreState;
46 const persistedUiStateByThreadKey =
47 candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {};
48 const terminalUiStateByThreadKey = Object.fromEntries(
49 Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) =>
50 parseScopedThreadKey(threadKey),
51 ),
52 );
53
54 return { terminalUiStateByThreadKey };
55}
56
57function createTerminalUiStateStorage() {
58 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
59}
60
61function normalizeTerminalIds(terminalIds: string[]): string[] {
62 const normalizedIds: string[] = [];
63 const seen = new Set<string>();
64 for (const id of terminalIds) {
65 const trimmedId = id.trim();
66 if (trimmedId.length === 0 || seen.has(trimmedId)) continue;
67 seen.add(trimmedId);
68 normalizedIds.push(trimmedId);
69 }
70 return normalizedIds;
71}
72
73function fallbackGroupId(terminalId: string): string {
74 return `group-${terminalId}`;
75}
76
77function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {
78 let candidate = baseId;
79 let index = 2;
80 while (usedGroupIds.has(candidate)) {
81 candidate = `${baseId}-${index}`;
82 index += 1;
83 }
84 usedGroupIds.add(candidate);
85 return candidate;
86}
87
88function findGroupIndexByTerminalId(
89 terminalGroups: ThreadTerminalGroup[],
90 terminalId: string,
91): number {
92 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
93}
94
95function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
96 return normalizeTerminalIds(terminalIds);
97}
98
99function normalizeTerminalGroups(
100 terminalGroups: ThreadTerminalGroup[],
101 terminalIds: string[],
102): ThreadTerminalGroup[] {
103 if (terminalIds.length === 0) {
104 return [];
105 }
106
107 const validTerminalIdSet = new Set(terminalIds);
108 const assignedTerminalIds = new Set<string>();
109 const nextGroups: ThreadTerminalGroup[] = [];
110 const usedGroupIds = new Set<string>();
111
112 for (const group of terminalGroups) {
113 const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {
114 if (!validTerminalIdSet.has(terminalId)) return false;
115 if (assignedTerminalIds.has(terminalId)) return false;
116 return true;
117 });
118 if (groupTerminalIds.length === 0) continue;
119 for (const terminalId of groupTerminalIds) {
120 assignedTerminalIds.add(terminalId);
121 }
122 const baseGroupId =
123 group.id.trim().length > 0
124 ? group.id.trim()
125 : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? "");
126 nextGroups.push({
127 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
128 terminalIds: groupTerminalIds,
129 });
130 }
131
132 for (const terminalId of terminalIds) {
133 if (assignedTerminalIds.has(terminalId)) continue;
134 nextGroups.push({
135 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
136 terminalIds: [terminalId],
137 });
138 }
139
140 return nextGroups;
141}
142
143function arraysEqual(a: string[], b: string[]): boolean {
144 if (a.length !== b.length) return false;
145 for (let index = 0; index < a.length; index += 1) {
146 if (a[index] !== b[index]) return false;
147 }
148 return true;
149}
150
151function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean {
152 if (left.length !== right.length) return false;
153 for (let index = 0; index < left.length; index += 1) {
154 const leftGroup = left[index];
155 const rightGroup = right[index];
156 if (!leftGroup || !rightGroup) return false;
157 if (leftGroup.id !== rightGroup.id) return false;
158 if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;
159 }
160 return true;
161}
162
163function threadTerminalUiStateEqual(
164 left: ThreadTerminalUiState,
165 right: ThreadTerminalUiState,
166): boolean {
167 return (
168 left.terminalOpen === right.terminalOpen &&
169 left.terminalHeight === right.terminalHeight &&
170 left.activeTerminalId === right.activeTerminalId &&
171 left.activeTerminalGroupId === right.activeTerminalGroupId &&
172 arraysEqual(left.terminalIds, right.terminalIds) &&
173 terminalGroupsEqual(left.terminalGroups, right.terminalGroups)
174 );
175}
176
177const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({
178 terminalOpen: false,
179 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
180 terminalIds: [],
181 activeTerminalId: "",
182 terminalGroups: [],
183 activeTerminalGroupId: "",
184});
185
186function createDefaultThreadTerminalUiState(): ThreadTerminalUiState {
187 return {
188 ...DEFAULT_THREAD_TERMINAL_UI_STATE,
189 terminalIds: [...DEFAULT_THREAD_TERMINAL_UI_STATE.terminalIds],
190 terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_UI_STATE.terminalGroups),
191 };
192}
193
194function getDefaultThreadTerminalUiState(): ThreadTerminalUiState {
195 return DEFAULT_THREAD_TERMINAL_UI_STATE;
196}
197
198function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState {
199 const nextTerminalIds = normalizeTerminalIds(state.terminalIds);
200 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
201 ? state.activeTerminalId
202 : (nextTerminalIds[0] ?? "");
203 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
204 const activeGroupIdFromState = terminalGroups.some(
205 (group) => group.id === state.activeTerminalGroupId,
206 )
207 ? state.activeTerminalGroupId
208 : null;
209 const activeGroupIdFromTerminal =
210 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
211
212 const normalized: ThreadTerminalUiState = {
213 terminalOpen: state.terminalOpen,
214 terminalHeight:
215 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
216 ? state.terminalHeight
217 : DEFAULT_THREAD_TERMINAL_HEIGHT,
218 terminalIds: nextTerminalIds,
219 activeTerminalId,
220 terminalGroups,
221 activeTerminalGroupId:
222 activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "",
223 };
224 return threadTerminalUiStateEqual(state, normalized) ? state : normalized;
225}
226
227function isDefaultThreadTerminalUiState(state: ThreadTerminalUiState): boolean {
228 const normalized = normalizeThreadTerminalUiState(state);
229 return threadTerminalUiStateEqual(normalized, DEFAULT_THREAD_TERMINAL_UI_STATE);
230}
231
232function isValidTerminalId(terminalId: string): boolean {
233 return terminalId.trim().length > 0;
234}
235
236function terminalThreadKey(threadRef: ScopedThreadRef): string {
237 return scopedThreadKey(threadRef);
238}
239
240function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
241 return groups.map((group) => ({
242 id: group.id,
243 terminalIds: [...group.terminalIds],
244 }));
245}
246
247function upsertTerminalIntoGroups(
248 state: ThreadTerminalUiState,
249 terminalId: string,
250 mode: "split" | "new",
251): ThreadTerminalUiState {
252 const normalized = normalizeThreadTerminalUiState(state);
253 const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode;
254 if (!isValidTerminalId(terminalId)) {
255 return normalized;
256 }
257
258 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
259 const terminalIds = isNewTerminal
260 ? [...normalized.terminalIds, terminalId]
261 : normalized.terminalIds;
262 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
263
264 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
265 if (existingGroupIndex >= 0) {
266 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
267 existingGroupIndex
268 ]!.terminalIds.filter((id) => id !== terminalId);
269 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
270 terminalGroups.splice(existingGroupIndex, 1);
271 }
272 }
273
274 if (effectiveMode === "new") {
275 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
276 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
277 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
278 return normalizeThreadTerminalUiState({
279 ...normalized,
280 terminalOpen: true,
281 terminalIds,
282 activeTerminalId: terminalId,
283 terminalGroups,
284 activeTerminalGroupId: nextGroupId,
285 });
286 }
287
288 let activeGroupIndex = terminalGroups.findIndex(
289 (group) => group.id === normalized.activeTerminalGroupId,
290 );
291 if (activeGroupIndex < 0) {
292 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
293 }
294 if (activeGroupIndex < 0) {
295 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
296 const nextGroupId = assignUniqueGroupId(
297 fallbackGroupId(normalized.activeTerminalId),
298 usedGroupIds,
299 );
300 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
301 activeGroupIndex = terminalGroups.length - 1;
302 }
303
304 const destinationGroup = terminalGroups[activeGroupIndex];
305 if (!destinationGroup) {
306 return normalized;
307 }
308 const destinationTerminalIdSet = new Set(destinationGroup.terminalIds);
309
310 if (
311 isNewTerminal &&
312 !destinationTerminalIdSet.has(terminalId) &&
313 destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP
314 ) {
315 return normalized;
316 }
317
318 if (!destinationTerminalIdSet.has(terminalId)) {
319 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
320 if (anchorIndex >= 0) {
321 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
322 } else {
323 destinationGroup.terminalIds.push(terminalId);
324 }
325 }
326
327 return normalizeThreadTerminalUiState({
328 ...normalized,
329 terminalOpen: true,
330 terminalIds,
331 activeTerminalId: terminalId,
332 terminalGroups,
333 activeTerminalGroupId: destinationGroup.id,
334 });
335}
336
337function setThreadTerminalOpen(state: ThreadTerminalUiState, open: boolean): ThreadTerminalUiState {
338 const normalized = normalizeThreadTerminalUiState(state);
339 if (open && normalized.terminalIds.length === 0) {
340 return upsertTerminalIntoGroups(normalized, DEFAULT_THREAD_TERMINAL_ID, "new");
341 }
342 if (normalized.terminalOpen === open) return normalized;
343 return { ...normalized, terminalOpen: open };
344}
345
346function setThreadTerminalHeight(
347 state: ThreadTerminalUiState,
348 height: number,
349): ThreadTerminalUiState {
350 const normalized = normalizeThreadTerminalUiState(state);
351 if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) {
352 return normalized;
353 }
354 return { ...normalized, terminalHeight: height };
355}
356
357function splitThreadTerminal(
358 state: ThreadTerminalUiState,
359 terminalId: string,
360): ThreadTerminalUiState {
361 return upsertTerminalIntoGroups(state, terminalId, "split");
362}
363
364function newThreadTerminal(
365 state: ThreadTerminalUiState,
366 terminalId: string,
367): ThreadTerminalUiState {
368 return upsertTerminalIntoGroups(state, terminalId, "new");
369}
370
371function setThreadActiveTerminal(
372 state: ThreadTerminalUiState,
373 terminalId: string,
374): ThreadTerminalUiState {
375 const normalized = normalizeThreadTerminalUiState(state);
376 if (!normalized.terminalIds.includes(terminalId)) {
377 return normalized;
378 }
379 const activeTerminalGroupId =
380 normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ??
381 normalized.activeTerminalGroupId;
382 if (
383 normalized.activeTerminalId === terminalId &&
384 normalized.activeTerminalGroupId === activeTerminalGroupId
385 ) {
386 return normalized;
387 }
388 return {
389 ...normalized,
390 activeTerminalId: terminalId,
391 activeTerminalGroupId,
392 };
393}
394
395function closeThreadTerminal(
396 state: ThreadTerminalUiState,
397 terminalId: string,
398): ThreadTerminalUiState {
399 const normalized = normalizeThreadTerminalUiState(state);
400 if (!normalized.terminalIds.includes(terminalId)) {
401 return normalized;
402 }
403
404 const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId);
405 if (remainingTerminalIds.length === 0) {
406 return createDefaultThreadTerminalUiState();
407 }
408
409 const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId);
410 const nextActiveTerminalId =
411 normalized.activeTerminalId === terminalId
412 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
413 remainingTerminalIds[0] ??
414 "")
415 : normalized.activeTerminalId;
416
417 const terminalGroups: ThreadTerminalGroup[] = [];
418 for (const group of normalized.terminalGroups) {
419 const terminalIds = group.terminalIds.filter((id) => id !== terminalId);
420 if (terminalIds.length > 0) {
421 terminalGroups.push({ ...group, terminalIds });
422 }
423 }
424
425 const nextActiveTerminalGroupId =
426 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??
427 terminalGroups[0]?.id ??
428 fallbackGroupId(nextActiveTerminalId);
429
430 return normalizeThreadTerminalUiState({
431 terminalOpen: normalized.terminalOpen,
432 terminalHeight: normalized.terminalHeight,
433 terminalIds: remainingTerminalIds,
434 activeTerminalId: nextActiveTerminalId,
435 terminalGroups,
436 activeTerminalGroupId: nextActiveTerminalGroupId,
437 });
438}
439
440function reconcileThreadTerminalSessionIds(
441 state: ThreadTerminalUiState,
442 nextIds: string[],
443): ThreadTerminalUiState {
444 const normalized = normalizeThreadTerminalUiState(state);
445 if (arraysEqual(normalized.terminalIds, nextIds)) {
446 return normalized;
447 }
448
449 const nextActiveTerminalId = nextIds.includes(normalized.activeTerminalId)
450 ? normalized.activeTerminalId
451 : (nextIds[0] ?? "");
452
453 const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds);
454 const activeGroupIdFromTerminal =
455 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null;
456
457 return normalizeThreadTerminalUiState({
458 ...normalized,
459 terminalIds: nextIds,
460 activeTerminalId: nextActiveTerminalId,
461 terminalGroups,
462 activeTerminalGroupId: activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "",
463 });
464}
465
466export function selectThreadTerminalUiState(
467 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
468 threadRef: ScopedThreadRef | null | undefined,
469): ThreadTerminalUiState {
470 if (!threadRef || threadRef.threadId.length === 0) {
471 return getDefaultThreadTerminalUiState();
472 }
473 return (
474 terminalUiStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalUiState()
475 );
476}
477
478function updateTerminalUiStateByThreadKey(
479 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
480 threadRef: ScopedThreadRef,
481 updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState,
482): Record<string, ThreadTerminalUiState> {
483 if (threadRef.threadId.length === 0) {
484 return terminalUiStateByThreadKey;
485 }
486
487 const threadKey = terminalThreadKey(threadRef);
488 const current = selectThreadTerminalUiState(terminalUiStateByThreadKey, threadRef);
489 const next = updater(current);
490 if (next === current) {
491 return terminalUiStateByThreadKey;
492 }
493
494 if (isDefaultThreadTerminalUiState(next)) {
495 if (terminalUiStateByThreadKey[threadKey] === undefined) {
496 return terminalUiStateByThreadKey;
497 }
498 const { [threadKey]: _removed, ...rest } = terminalUiStateByThreadKey;
499 return rest;
500 }
501
502 return {
503 ...terminalUiStateByThreadKey,
504 [threadKey]: next,
505 };
506}
507
508interface TerminalUiStateStoreState {
509 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>;
510 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
511 setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void;
512 splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
513 newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
514 ensureTerminal: (
515 threadRef: ScopedThreadRef,
516 terminalId: string,
517 options?: { open?: boolean; active?: boolean },
518 ) => void;
519 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
520 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
521 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;
522 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;
523 removeTerminalUiState: (threadRef: ScopedThreadRef) => void;
524 removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void;
525}
526
527export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
528 persist(
529 (set) => {
530 const updateTerminal = (
531 threadRef: ScopedThreadRef,
532 updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState,
533 ) => {
534 set((state) => {
535 const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey(
536 state.terminalUiStateByThreadKey,
537 threadRef,
538 updater,
539 );
540 if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) {
541 return state;
542 }
543 return {
544 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
545 };
546 });
547 };
548
549 return {
550 terminalUiStateByThreadKey: {},
551 setTerminalOpen: (threadRef, open) =>
552 updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)),
553 setTerminalHeight: (threadRef, height) =>
554 updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)),
555 splitTerminal: (threadRef, terminalId) =>
556 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)),
557 newTerminal: (threadRef, terminalId) =>
558 updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)),
559 ensureTerminal: (threadRef, terminalId, options) =>
560 updateTerminal(threadRef, (state) => {
561 let nextState = state;
562 if (!state.terminalIds.includes(terminalId)) {
563 nextState = newThreadTerminal(nextState, terminalId);
564 }
565 if (options?.active === false) {
566 nextState = {
567 ...nextState,
568 activeTerminalId: state.activeTerminalId,
569 activeTerminalGroupId: state.activeTerminalGroupId,
570 };
571 }
572 if (options?.active ?? true) {
573 nextState = setThreadActiveTerminal(nextState, terminalId);
574 }
575 if (options?.open) {
576 nextState = setThreadTerminalOpen(nextState, true);
577 }
578 return normalizeThreadTerminalUiState(nextState);
579 }),
580 setActiveTerminal: (threadRef, terminalId) =>
581 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),
582 closeTerminal: (threadRef, terminalId) =>
583 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)),
584 reconcileTerminalIds: (threadRef, nextIds) =>
585 updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)),
586 clearTerminalUiState: (threadRef) =>
587 set((state) => {
588 const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey(
589 state.terminalUiStateByThreadKey,
590 threadRef,
591 () => createDefaultThreadTerminalUiState(),
592 );
593 if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) {
594 return state;
595 }
596 return {
597 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
598 };
599 }),
600 removeTerminalUiState: (threadRef) =>
601 set((state) => {
602 const threadKey = terminalThreadKey(threadRef);
603 const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined;
604 if (!hadTerminalUiState) {
605 return state;
606 }
607 const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey };
608 delete nextTerminalUiStateByThreadKey[threadKey];
609 return {
610 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
611 };
612 }),
613 removeOrphanedTerminalUiStates: (activeThreadKeys) =>
614 set((state) => {
615 const orphanedIds = Object.keys(state.terminalUiStateByThreadKey).filter(
616 (key) => !activeThreadKeys.has(key),
617 );
618 if (orphanedIds.length === 0) {
619 return state;
620 }
621 const next = { ...state.terminalUiStateByThreadKey };
622 for (const id of orphanedIds) {
623 delete next[id];
624 }
625 return {
626 terminalUiStateByThreadKey: next,
627 };
628 }),
629 };
630 },
631 {
632 name: TERMINAL_UI_STATE_STORAGE_KEY,
633 version: 4,
634 storage: createJSONStorage(createTerminalUiStateStorage),
635 migrate: migratePersistedTerminalUiStateStoreState,
636 partialize: (state) => ({
637 terminalUiStateByThreadKey: state.terminalUiStateByThreadKey,
638 }),
639 },
640 ),
641);
apps/web/src/types.ts+1-1
...@@ -23,7 +23,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";...@@ -23,7 +23,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
2323
24export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";24export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";
25export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;25export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
26export const DEFAULT_THREAD_TERMINAL_ID = "default";26export const DEFAULT_THREAD_TERMINAL_ID = "term-1";
27export const MAX_TERMINALS_PER_GROUP = 4;27export const MAX_TERMINALS_PER_GROUP = 4;
28export type ProjectScript = ContractProjectScript;28export type ProjectScript = ContractProjectScript;
2929
apps/web/test/wsRpcHarness.ts+3
...@@ -30,10 +30,13 @@ const STREAM_METHODS = new Set<string>([...@@ -30,10 +30,13 @@ const STREAM_METHODS = new Set<string>([
30 ORCHESTRATION_WS_METHODS.subscribeShell,30 ORCHESTRATION_WS_METHODS.subscribeShell,
31 ORCHESTRATION_WS_METHODS.subscribeThread,31 ORCHESTRATION_WS_METHODS.subscribeThread,
32 WS_METHODS.gitRunStackedAction,32 WS_METHODS.gitRunStackedAction,
33 WS_METHODS.terminalAttach,
33 WS_METHODS.subscribeVcsStatus,34 WS_METHODS.subscribeVcsStatus,
34 WS_METHODS.subscribeTerminalEvents,35 WS_METHODS.subscribeTerminalEvents,
36 WS_METHODS.subscribeTerminalMetadata,
35 WS_METHODS.subscribeServerConfig,37 WS_METHODS.subscribeServerConfig,
36 WS_METHODS.subscribeServerLifecycle,38 WS_METHODS.subscribeServerLifecycle,
39 WS_METHODS.subscribeAuthAccess,
37]);40]);
3841
39const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys());42const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys());
apps/web/vitest.config.ts created+16
...@@ -0,0 +1,16 @@
1import { defineConfig, mergeConfig } from "vitest/config";
2
3import viteConfig from "./vite.config";
4
5export default mergeConfig(
6 viteConfig,
7 defineConfig({
8 test: {
9 // The web runtime suite exercises auth bootstrap, saved environments,
10 // and websocket subscription lifecycles. Under the full monorepo test
11 // run, those async tests can exceed Vitest's default 5s budget.
12 testTimeout: 15_000,
13 hookTimeout: 15_000,
14 },
15 }),
16);
bun.lock+1187-59
...@@ -46,6 +46,74 @@...@@ -46,6 +46,74 @@
46 "typescript": "catalog:",46 "typescript": "catalog:",
47 },47 },
48 },48 },
49 "apps/mobile": {
50 "name": "@t3tools/mobile",
51 "version": "0.0.0",
52 "dependencies": {
53 "@callstack/liquid-glass": "^0.7.1",
54 "@effect/atom-react": "catalog:",
55 "@expo-google-fonts/dm-sans": "^0.4.2",
56 "@expo/ui": "~56.0.8",
57 "@legendapp/list": "3.0.0-beta.44",
58 "@pierre/diffs": "catalog:",
59 "@react-native-menu/menu": "^2.0.0",
60 "@shikijs/core": "3.23.0",
61 "@shikijs/engine-javascript": "3.23.0",
62 "@shikijs/langs": "3.23.0",
63 "@shikijs/themes": "3.23.0",
64 "@t3tools/client-runtime": "workspace:*",
65 "@t3tools/contracts": "workspace:*",
66 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
67 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
68 "@t3tools/shared": "workspace:*",
69 "clsx": "^2.1.1",
70 "diff": "8.0.3",
71 "effect": "catalog:",
72 "expo": "^56.0.0",
73 "expo-build-properties": "~56.0.15",
74 "expo-camera": "~56.0.7",
75 "expo-clipboard": "~56.0.3",
76 "expo-constants": "~56.0.16",
77 "expo-crypto": "~56.0.4",
78 "expo-dev-client": "~56.0.16",
79 "expo-file-system": "~56.0.7",
80 "expo-font": "~56.0.5",
81 "expo-glass-effect": "~56.0.4",
82 "expo-haptics": "~56.0.3",
83 "expo-image-picker": "~56.0.14",
84 "expo-linking": "~56.0.12",
85 "expo-paste-input": "^0.1.15",
86 "expo-router": "~56.2.7",
87 "expo-secure-store": "~56.0.4",
88 "expo-splash-screen": "~56.0.10",
89 "expo-symbols": "~56.0.5",
90 "expo-updates": "~56.0.17",
91 "punycode": "^2.3.1",
92 "react": "19.2.3",
93 "react-dom": "19.2.3",
94 "react-native": "0.85.3",
95 "react-native-gesture-handler": "~2.31.1",
96 "react-native-image-viewing": "^0.2.2",
97 "react-native-keyboard-controller": "1.21.6",
98 "react-native-nitro-markdown": "^0.5.0",
99 "react-native-nitro-modules": "^0.35.4",
100 "react-native-reanimated": "4.3.1",
101 "react-native-safe-area-context": "~5.7.0",
102 "react-native-screens": "4.25.2",
103 "react-native-shiki-engine": "^0.3.9",
104 "react-native-svg": "15.15.4",
105 "react-native-worklets": "0.8.3",
106 "shiki": "3.23.0",
107 "tailwind-merge": "^3.5.0",
108 "uniwind": "^1.6.2",
109 },
110 "devDependencies": {
111 "@types/react": "~19.2.0",
112 "babel-preset-expo": "~56.0.0",
113 "tailwindcss": "^4.0.0",
114 "typescript": "catalog:",
115 },
116 },
49 "apps/server": {117 "apps/server": {
50 "name": "t3",118 "name": "t3",
51 "version": "0.0.24",119 "version": "0.0.24",
...@@ -150,6 +218,7 @@...@@ -150,6 +218,7 @@
150 "effect": "catalog:",218 "effect": "catalog:",
151 },219 },
152 "devDependencies": {220 "devDependencies": {
221 "@effect/vitest": "catalog:",
153 "vitest": "catalog:",222 "vitest": "catalog:",
154 },223 },
155 },224 },
...@@ -251,6 +320,7 @@...@@ -251,6 +320,7 @@
251 ],320 ],
252 "patchedDependencies": {321 "patchedDependencies": {
253 "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch",322 "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch",
323 "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch",
254 },324 },
255 "overrides": {325 "overrides": {
256 "@effect/atom-react": "catalog:",326 "@effect/atom-react": "catalog:",
...@@ -281,6 +351,8 @@...@@ -281,6 +351,8 @@
281 "vitest": "^4.0.0",351 "vitest": "^4.0.0",
282 },352 },
283 "packages": {353 "packages": {
354 "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
355
284 "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.111", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-DwXyJpVL8JXB8L2toSw1by7uIt1p8hPGi0P+hqr5tL+Ae7DcK9O3tUd6XcGown3LZ49zNCUAIpqX3wDmOhqp0Q=="],356 "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.111", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-DwXyJpVL8JXB8L2toSw1by7uIt1p8hPGi0P+hqr5tL+Ae7DcK9O3tUd6XcGown3LZ49zNCUAIpqX3wDmOhqp0Q=="],
285357
286 "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="],358 "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="],
...@@ -323,33 +395,137 @@...@@ -323,33 +395,137 @@
323395
324 "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],396 "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
325397
326 "@babel/generator": ["@babel/generator@8.0.0-rc.2", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.2", "@babel/types": "^8.0.0-rc.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ=="],398 "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
399
400 "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="],
327401
328 "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],402 "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
329403
404 "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.3", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.29.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA=="],
405
406 "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw=="],
407
408 "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="],
409
330 "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],410 "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
331411
412 "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="],
413
332 "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],414 "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
333415
334 "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],416 "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
335417
418 "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="],
419
336 "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],420 "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
337421
422 "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-wrap-function": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA=="],
423
424 "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="],
425
426 "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="],
427
338 "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],428 "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
339429
340 "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.2", "", {}, "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A=="],430 "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.2", "", {}, "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A=="],
341431
342 "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],432 "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
343433
434 "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ=="],
435
344 "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],436 "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
345437
346 "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],438 "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
347439
440 "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-decorators": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA=="],
441
442 "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw=="],
443
444 "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA=="],
445
446 "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="],
447
448 "@babel/plugin-syntax-export-default-from": ["@babel/plugin-syntax-export-default-from@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ=="],
449
450 "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew=="],
451
348 "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],452 "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
349453
454 "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="],
455
456 "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="],
457
350 "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],458 "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
351459
352 "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],460 "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="],
461
462 "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", "@babel/traverse": "^7.29.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w=="],
463
464 "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g=="],
465
466 "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw=="],
467
468 "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
469
470 "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.28.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ=="],
471
472 "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
473
474 "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw=="],
475
476 "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ=="],
477
478 "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-flow": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg=="],
479
480 "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw=="],
481
482 "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A=="],
483
484 "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="],
485
486 "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.0", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ=="],
487
488 "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
489
490 "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.28.6", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-parameters": "^7.27.7", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA=="],
491
492 "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ=="],
493
494 "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
495
496 "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.27.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg=="],
497
498 "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.28.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg=="],
499
500 "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA=="],
501
502 "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA=="],
503
504 "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-jsx": "^7.28.6", "@babel/types": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow=="],
505
506 "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.27.1", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q=="],
507
508 "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
509
510 "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
511
512 "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA=="],
513
514 "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog=="],
515
516 "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.29.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w=="],
517
518 "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ=="],
519
520 "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg=="],
521
522 "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="],
523
524 "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw=="],
525
526 "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
527
528 "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
353529
354 "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],530 "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
355531
...@@ -363,6 +539,8 @@...@@ -363,6 +539,8 @@
363539
364 "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="],540 "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="],
365541
542 "@callstack/liquid-glass": ["@callstack/liquid-glass@0.7.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-N2rzs8g3kneI5G/98AZdVtjc6OUFBBFwPxVGVMoXUEI7QiioB4+aWwbImg5h8Z3Vb/T+fgLYWmmNhi27wHwE4g=="],
543
366 "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="],544 "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="],
367545
368 "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="],546 "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="],
...@@ -409,6 +587,8 @@...@@ -409,6 +587,8 @@
409587
410 "@effect/vitest": ["@effect/vitest@4.0.0-beta.73", "", { "peerDependencies": { "effect": "^4.0.0-beta.73", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-HIZa0aekh0ak1YfzCv8GTN75Vbvxp8g+W+z9KczvtUT+LvitX3i9nhLWZLKRrUP1LaJFLPJS2UG9ZGXjxgbjBg=="],588 "@effect/vitest": ["@effect/vitest@4.0.0-beta.73", "", { "peerDependencies": { "effect": "^4.0.0-beta.73", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-HIZa0aekh0ak1YfzCv8GTN75Vbvxp8g+W+z9KczvtUT+LvitX3i9nhLWZLKRrUP1LaJFLPJS2UG9ZGXjxgbjBg=="],
411589
590 "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
591
412 "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],592 "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
413593
414 "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],594 "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
...@@ -483,6 +663,76 @@...@@ -483,6 +663,76 @@
483663
484 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],664 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
485665
666 "@expo-google-fonts/dm-sans": ["@expo-google-fonts/dm-sans@0.4.2", "", {}, "sha512-e/fLH5aCJzkEenz7axvEn8N7IJAKhUwNjIxS75h9j+Ip1M9S7d22M19gJN3A7QW4egVkBJRyQh1hQFhngQn9yA=="],
667
668 "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
669
670 "@expo/cli": ["@expo/cli@56.1.12", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.10", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.0", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.13", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.12", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.4", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA=="],
671
672 "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="],
673
674 "@expo/config": ["@expo/config@56.0.9", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", "@expo/json-file": "^10.2.0", "@expo/require-utils": "^56.1.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw=="],
675
676 "@expo/config-plugins": ["@expo/config-plugins@56.0.8", "", { "dependencies": { "@expo/config-types": "^56.0.5", "@expo/json-file": "~10.2.0", "@expo/plist": "^0.7.0", "@expo/require-utils": "^56.1.3", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg=="],
677
678 "@expo/config-types": ["@expo/config-types@56.0.5", "", {}, "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA=="],
679
680 "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="],
681
682 "@expo/devtools": ["@expo/devtools@56.0.2", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ=="],
683
684 "@expo/dom-webview": ["@expo/dom-webview@56.0.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew=="],
685
686 "@expo/env": ["@expo/env@2.3.0", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg=="],
687
688 "@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.0.9", "", {}, "sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA=="],
689
690 "@expo/fingerprint": ["@expo/fingerprint@0.19.3", "", { "dependencies": { "@expo/env": "^2.3.0", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-w9Au2IVrtc0Ct+WRa05DVHGNHXYq6VyPZWuFP+5x055OeZ5q6k5Yg+aJ1gfShmjdOhDftRcsvmWmTdTZlWaTZw=="],
691
692 "@expo/image-utils": ["@expo/image-utils@0.10.1", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg=="],
693
694 "@expo/inline-modules": ["@expo/inline-modules@0.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8" } }, "sha512-DKEfq877UTAmL/gOT5aFhlLNDg/EVmTSca7JQMKDGR6mjFSAcrmQf4GJNsi6ztiaqj6cTnIfoSz0hAYdnr6RJQ=="],
695
696 "@expo/json-file": ["@expo/json-file@10.2.0", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ=="],
697
698 "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@56.0.8", "", { "dependencies": { "@expo/config": "~56.0.9", "chalk": "^4.1.2" } }, "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ=="],
699
700 "@expo/log-box": ["@expo/log-box@56.0.12", "", { "dependencies": { "@expo/dom-webview": "^56.0.5", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ=="],
701
702 "@expo/metro": ["@expo/metro@56.0.0", "", { "dependencies": { "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-minify-terser": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4" } }, "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A=="],
703
704 "@expo/metro-config": ["@expo/metro-config@56.0.13", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~56.0.9", "@expo/env": "~2.3.0", "@expo/json-file": "~10.2.0", "@expo/metro": "~56.0.0", "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.33.3", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "msgpackr": "^2.0.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA=="],
705
706 "@expo/metro-file-map": ["@expo/metro-file-map@56.0.3", "", { "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w=="],
707
708 "@expo/metro-runtime": ["@expo/metro-runtime@55.0.11", "", { "dependencies": { "@expo/log-box": "55.0.12", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-4KKi/jGrIEXi2YGu0hYTVr0CEeRJy5SXbCrz9+KDZkuD3ROwKNpM1DBawni5rhPVovFnR323HBck9GaxhnfrRw=="],
709
710 "@expo/osascript": ["@expo/osascript@2.6.0", "", { "dependencies": { "@expo/spawn-async": "^1.8.0" } }, "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg=="],
711
712 "@expo/package-manager": ["@expo/package-manager@1.12.0", "", { "dependencies": { "@expo/json-file": "^10.2.0", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg=="],
713
714 "@expo/plist": ["@expo/plist@0.7.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q=="],
715
716 "@expo/prebuild-config": ["@expo/prebuild-config@56.0.13", "", { "dependencies": { "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", "@expo/image-utils": "^0.10.1", "@expo/json-file": "^10.2.0", "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.1", "expo-modules-autolinking": "~56.0.13", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-caR1karpDasbNmM+LrcHKZrSnyEYdmxm7kedq+WjiuZg+9XAW5sbEjojo2i9Dq6cfbDJPyr7I0yEprLabnvmpA=="],
717
718 "@expo/require-utils": ["@expo/require-utils@56.1.3", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ=="],
719
720 "@expo/router-server": ["@expo/router-server@56.0.12", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^56.0.13", "expo": "*", "expo-constants": "^56.0.16", "expo-font": "^56.0.5", "expo-router": "*", "expo-server": "^56.0.4", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-RqKV2/Z8BH/z8l0ngSpG6//5xxJPaF5dTQvSfPQ0nrvCjikGMeIvyj3B9BeLnmZZhxb3gBtXqrj3irAoiIp2aQ=="],
721
722 "@expo/schema-utils": ["@expo/schema-utils@56.0.1", "", {}, "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w=="],
723
724 "@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="],
725
726 "@expo/spawn-async": ["@expo/spawn-async@1.8.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw=="],
727
728 "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="],
729
730 "@expo/ui": ["@expo/ui@56.0.14", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-reanimated": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-reanimated", "react-native-worklets"] }, "sha512-0Wr8nsvk2C+BmhmZDQzYr/hxxddHK+ajuJ7ahacUvxt+gQnEXwbueTm0S/hk/54YGASEgplrPGDuR5zzcY+IZg=="],
731
732 "@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.6", "", {}, "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q=="],
733
734 "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
735
486 "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],736 "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
487737
488 "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],738 "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
...@@ -559,12 +809,20 @@...@@ -559,12 +809,20 @@
559809
560 "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],810 "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
561811
812 "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="],
813
814 "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
815
816 "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
817
562 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],818 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
563819
564 "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],820 "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
565821
566 "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],822 "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
567823
824 "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
825
568 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],826 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
569827
570 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],828 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
...@@ -735,6 +993,80 @@...@@ -735,6 +993,80 @@
735993
736 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],994 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
737995
996 "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
997
998 "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
999
1000 "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
1001
1002 "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
1003
1004 "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
1005
1006 "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
1007
1008 "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
1009
1010 "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
1011
1012 "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
1013
1014 "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
1015
1016 "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
1017
1018 "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
1019
1020 "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
1021
1022 "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
1023
1024 "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
1025
1026 "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
1027
1028 "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
1029
1030 "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
1031
1032 "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
1033
1034 "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
1035
1036 "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
1037
1038 "@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="],
1039
1040 "@react-native-menu/menu": ["@react-native-menu/menu@2.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-hb8Mirw6aKPGONhgo52IiNpwHtISVrgCT3rMdFX1qS7eOFNzOcQh8d2UDnaH5zVpxN+QuvWtaaiRMGFpIjzdtA=="],
1041
1042 "@react-native/assets-registry": ["@react-native/assets-registry@0.85.3", "", {}, "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg=="],
1043
1044 "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.85.3", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.85.3" } }, "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA=="],
1045
1046 "@react-native/babel-preset": ["@react-native/babel-preset@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-syntax-hermes-parser": "0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw=="],
1047
1048 "@react-native/codegen": ["@react-native/codegen@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.33.3", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA=="],
1049
1050 "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.85.3", "", { "dependencies": { "@react-native/dev-middleware": "0.85.3", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", "metro-config": "^0.84.3", "metro-core": "^0.84.3", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.85.3" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw=="],
1051
1052 "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.85.3", "", {}, "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A=="],
1053
1054 "@react-native/debugger-shell": ["@react-native/debugger-shell@0.85.3", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ=="],
1055
1056 "@react-native/dev-middleware": ["@react-native/dev-middleware@0.85.3", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.85.3", "@react-native/debugger-shell": "0.85.3", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA=="],
1057
1058 "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.85.3", "", {}, "sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA=="],
1059
1060 "@react-native/js-polyfills": ["@react-native/js-polyfills@0.85.3", "", {}, "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A=="],
1061
1062 "@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.85.3", "hermes-parser": "0.33.3", "nullthrows": "^1.1.1" } }, "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ=="],
1063
1064 "@react-native/metro-config": ["@react-native/metro-config@0.85.3", "", { "dependencies": { "@react-native/js-polyfills": "0.85.3", "@react-native/metro-babel-transformer": "0.85.3", "metro-config": "^0.84.3", "metro-runtime": "^0.84.3" } }, "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA=="],
1065
1066 "@react-native/normalize-colors": ["@react-native/normalize-colors@0.85.3", "", {}, "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw=="],
1067
1068 "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.85.3", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.85.3" }, "optionalPeers": ["@types/react"] }, "sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig=="],
1069
738 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="],1070 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="],
7391071
740 "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A=="],1072 "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A=="],
...@@ -771,24 +1103,26 @@...@@ -771,24 +1103,26 @@
7711103
772 "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],1104 "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
7731105
774 "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],1106 "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
7751107
776 "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],1108 "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
7771109
778 "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],1110 "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
7791111
780 "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],1112 "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
7811113
782 "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],1114 "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
7831115
784 "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],1116 "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
7851117
786 "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="],1118 "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="],
7871119
788 "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],1120 "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
7891121
790 "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],1122 "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
7911123
1124 "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
1125
792 "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],1126 "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
7931127
794 "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],1128 "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
...@@ -803,6 +1137,8 @@...@@ -803,6 +1137,8 @@
8031137
804 "@t3tools/marketing": ["@t3tools/marketing@workspace:apps/marketing"],1138 "@t3tools/marketing": ["@t3tools/marketing@workspace:apps/marketing"],
8051139
1140 "@t3tools/mobile": ["@t3tools/mobile@workspace:apps/mobile"],
1141
806 "@t3tools/oxlint-plugin-t3code": ["@t3tools/oxlint-plugin-t3code@workspace:oxlint-plugin-t3code"],1142 "@t3tools/oxlint-plugin-t3code": ["@t3tools/oxlint-plugin-t3code@workspace:oxlint-plugin-t3code"],
8071143
808 "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"],1144 "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"],
...@@ -873,8 +1209,16 @@...@@ -873,8 +1209,16 @@
8731209
874 "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="],1210 "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="],
8751211
1212 "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
1213
1214 "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
1215
1216 "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
1217
876 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],1218 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
8771219
1220 "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
1221
878 "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],1222 "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
8791223
880 "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],1224 "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
...@@ -893,14 +1237,24 @@...@@ -893,14 +1237,24 @@
8931237
894 "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],1238 "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
8951239
1240 "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
1241
896 "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],1242 "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
8971243
898 "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],1244 "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
8991245
1246 "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
1247
900 "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],1248 "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
9011249
902 "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],1250 "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
9031251
1252 "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
1253
1254 "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
1255
1256 "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
1257
904 "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],1258 "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
9051259
906 "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],1260 "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
...@@ -917,6 +1271,8 @@...@@ -917,6 +1271,8 @@
9171271
918 "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],1272 "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
9191273
1274 "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="],
1275
920 "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],1276 "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
9211277
922 "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],1278 "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
...@@ -925,6 +1281,10 @@...@@ -925,6 +1281,10 @@
9251281
926 "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],1282 "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
9271283
1284 "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
1285
1286 "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
1287
928 "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],1288 "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
9291289
930 "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260527.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260527.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260527.2" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-piqkDwikVeizCFqA1lcwI5F4wOAtBdxuliWe77ApBNRyBPPvfCJB+u/HYi9/8t5nd0sWvFs6/qt/AzJ1CCoykQ=="],1290 "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260527.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260527.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260527.2" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-piqkDwikVeizCFqA1lcwI5F4wOAtBdxuliWe77ApBNRyBPPvfCJB+u/HYi9/8t5nd0sWvFs6/qt/AzJ1CCoykQ=="],
...@@ -989,34 +1349,50 @@...@@ -989,34 +1349,50 @@
9891349
990 "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],1350 "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
9911351
1352 "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
1353
992 "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],1354 "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
9931355
994 "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],1356 "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
9951357
996 "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],1358 "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
1359
1360 "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
9971361
998 "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],1362 "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
9991363
1364 "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
1365
1000 "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],1366 "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
10011367
1002 "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],1368 "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
10031369
1004 "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],1370 "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
10051371
1372 "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="],
1373
1374 "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
1375
1006 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],1376 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
10071377
1008 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],1378 "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
10091379
1010 "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],1380 "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
10111381
1012 "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],1382 "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
10131383
1384 "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
1385
1014 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],1386 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
10151387
1388 "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
1389
1016 "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],1390 "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
10171391
1018 "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],1392 "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],
10191393
1394 "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
1395
1020 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],1396 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
10211397
1022 "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="],1398 "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="],
...@@ -1029,12 +1405,34 @@...@@ -1029,12 +1405,34 @@
10291405
1030 "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],1406 "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
10311407
1408 "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="],
1409
1410 "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="],
1411
1412 "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="],
1413
1032 "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],1414 "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
10331415
1416 "babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="],
1417
1418 "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.33.3", "", { "dependencies": { "hermes-parser": "0.33.3" } }, "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA=="],
1419
1420 "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="],
1421
1422 "babel-preset-expo": ["babel-preset-expo@56.0.13", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^56.0.15", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-+CxxAQrN95N+/dF4AUJXNxEh5cEv4yhxb4CM5ijdc2OeIIw+hxzYh2OM1X7QHIm6hkT66H4vJCTT636yjJ8MnQ=="],
1423
1034 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],1424 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
10351425
1426 "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
1427
1428 "barcode-detector": ["barcode-detector@3.1.3", "", { "dependencies": { "zxing-wasm": "3.0.3" } }, "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw=="],
1429
1430 "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
1431
1036 "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],1432 "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
10371433
1434 "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
1435
1038 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],1436 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
10391437
1040 "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="],1438 "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="],
...@@ -1045,12 +1443,22 @@...@@ -1045,12 +1443,22 @@
10451443
1046 "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],1444 "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
10471445
1446 "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
1447
1448 "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
1449
1450 "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
1451
1048 "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],1452 "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
10491453
1050 "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],1454 "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
10511455
1456 "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="],
1457
1052 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],1458 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
10531459
1460 "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
1461
1054 "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],1462 "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
10551463
1056 "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],1464 "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
...@@ -1067,12 +1475,16 @@...@@ -1067,12 +1475,16 @@
10671475
1068 "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],1476 "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
10691477
1478 "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
1479
1070 "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],1480 "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
10711481
1072 "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],1482 "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
10731483
1074 "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],1484 "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
10751485
1486 "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
1487
1076 "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],1488 "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
10771489
1078 "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],1490 "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
...@@ -1083,30 +1495,52 @@...@@ -1083,30 +1495,52 @@
10831495
1084 "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],1496 "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
10851497
1498 "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
1499
1500 "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="],
1501
1086 "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],1502 "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
10871503
1088 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],1504 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
10891505
1506 "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
1507
1508 "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
1509
1090 "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],1510 "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
10911511
1512 "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
1513
1092 "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],1514 "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
10931515
1516 "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
1517
1094 "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],1518 "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
10951519
1096 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],1520 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
10971521
1098 "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],1522 "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
10991523
1524 "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
1525
1100 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],1526 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
11011527
1102 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],1528 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
11031529
1530 "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
1531
1104 "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],1532 "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
11051533
1106 "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],1534 "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
11071535
1108 "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],1536 "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
11091537
1538 "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
1539
1540 "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="],
1541
1542 "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="],
1543
1110 "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],1544 "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
11111545
1112 "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],1546 "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
...@@ -1119,6 +1553,8 @@...@@ -1119,6 +1553,8 @@
11191553
1120 "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],1554 "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
11211555
1556 "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="],
1557
1122 "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],1558 "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
11231559
1124 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],1560 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
...@@ -1127,20 +1563,30 @@...@@ -1127,20 +1563,30 @@
11271563
1128 "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],1564 "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
11291565
1130 "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],1566 "css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="],
11311567
1132 "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],1568 "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
11331569
1570 "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
1571
1134 "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="],1572 "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="],
11351573
1136 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],1574 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
11371575
1576 "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
1577
1138 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],1578 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
11391579
1140 "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],1580 "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
11411581
1582 "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
1583
1142 "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],1584 "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
11431585
1586 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
1587
1588 "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
1589
1144 "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],1590 "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
11451591
1146 "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],1592 "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
...@@ -1157,10 +1603,14 @@...@@ -1157,10 +1603,14 @@
11571603
1158 "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],1604 "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
11591605
1606 "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
1607
1160 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],1608 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
11611609
1162 "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],1610 "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
11631611
1612 "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
1613
1164 "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],1614 "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
11651615
1166 "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],1616 "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
...@@ -1169,6 +1619,10 @@...@@ -1169,6 +1619,10 @@
11691619
1170 "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],1620 "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
11711621
1622 "dnssd-advertise": ["dnssd-advertise@1.1.4", "", {}, "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA=="],
1623
1624 "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
1625
1172 "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],1626 "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
11731627
1174 "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],1628 "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
...@@ -1213,6 +1667,8 @@...@@ -1213,6 +1667,8 @@
12131667
1214 "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],1668 "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
12151669
1670 "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
1671
1216 "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],1672 "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
12171673
1218 "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],1674 "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
...@@ -1239,6 +1695,8 @@...@@ -1239,6 +1695,8 @@
12391695
1240 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],1696 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
12411697
1698 "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
1699
1242 "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],1700 "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
12431701
1244 "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],1702 "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
...@@ -1247,6 +1705,76 @@...@@ -1247,6 +1705,76 @@
12471705
1248 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],1706 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
12491707
1708 "expo": ["expo@56.0.6", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.12", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.5", "@expo/fingerprint": "^0.19.3", "@expo/local-build-cache-provider": "^56.0.8", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.13", "expo-asset": "~56.0.15", "expo-constants": "~56.0.16", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.14", "expo-modules-core": "~56.0.13", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-zcFa/+6hGtzCUlcrGiusvzr/PIoNBAnjj4PlAFrvbAOZcVOj6c9Mp7lRSn9XYJk8Ok6pssQWt6dP4llJlKmYRQ=="],
1709
1710 "expo-asset": ["expo-asset@56.0.15", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "expo-constants": "~56.0.16" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ=="],
1711
1712 "expo-build-properties": ["expo-build-properties@56.0.15", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-3OlfTnBE6BIFxchjXzb0OlgDcWw19fxhIzpIZqgcgzZUVjyn4gCrQuNcsfazVVddBypwkEzOVfwArPROIk4J7g=="],
1713
1714 "expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
1715
1716 "expo-clipboard": ["expo-clipboard@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg=="],
1717
1718 "expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
1719
1720 "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
1721
1722 "expo-dev-client": ["expo-dev-client@56.0.16", "", { "dependencies": { "expo-dev-launcher": "~56.0.16", "expo-dev-menu": "~56.0.15", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-mxmGA6YSP4KiMB4bREpriQ4K6EaS4tcm0eh1+LtAzgFCytq+Y4WxMfIvFe3B5kXlSpA0ohMLdAN0AUzU0xHGQg=="],
1723
1724 "expo-dev-launcher": ["expo-dev-launcher@56.0.16", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "expo-dev-menu": "~56.0.15", "expo-manifests": "~56.0.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-3t2PCX2lCKetKL8EgRRo2tzSlGh1zcuaWuwp3V0k4/3nuM7pztyImaR6Sm3HUyarDOofAIPX1hIIxnuAfk5cnw=="],
1725
1726 "expo-dev-menu": ["expo-dev-menu@56.0.15", "", { "dependencies": { "expo-dev-menu-interface": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FY6Y5sZkNXxPBGDgC51ZArOi8N7Y8wpXwanTClFO36IVMoVf7BBqhjW13KpDecvJONtEtaUeNIAt9C25PO8MOQ=="],
1727
1728 "expo-dev-menu-interface": ["expo-dev-menu-interface@56.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA=="],
1729
1730 "expo-eas-client": ["expo-eas-client@56.0.1", "", {}, "sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA=="],
1731
1732 "expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
1733
1734 "expo-font": ["expo-font@56.0.5", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw=="],
1735
1736 "expo-glass-effect": ["expo-glass-effect@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA=="],
1737
1738 "expo-haptics": ["expo-haptics@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw=="],
1739
1740 "expo-image-loader": ["expo-image-loader@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA=="],
1741
1742 "expo-image-picker": ["expo-image-picker@56.0.14", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-OKirR7XcJC3awoSGYDzFIdPibTEzw9FL1KHoTSxf86ZmssBiFbJvJhb1JQAVe2Y/U0sQ4e9gMoOrCKwqJnGDEQ=="],
1743
1744 "expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="],
1745
1746 "expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
1747
1748 "expo-linking": ["expo-linking@56.0.12", "", { "dependencies": { "expo-constants": "~56.0.16", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-EJ+YoazVqlrUXMAARo1iTExpqEGjuKJDGiE/P1K+A3m5hs+2Uf8F9ucqpq9k5dizeiaV2D8B9+uLvqMHFzGGsQ=="],
1749
1750 "expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="],
1751
1752 "expo-modules-autolinking": ["expo-modules-autolinking@56.0.14", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg=="],
1753
1754 "expo-modules-core": ["expo-modules-core@56.0.13", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "~0.0.9", "expo-modules-jsi": "~56.0.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-3Hgpi9Q1O0XqoesQtgFY7qhfDsNA3bJtdCJotEqdE42+N8Zv/LJACbNgIyFN/XrnMDzfF5rozh0vNWaRT0/eXQ=="],
1755
1756 "expo-modules-jsi": ["expo-modules-jsi@56.0.7", "", { "peerDependencies": { "react-native": "*" } }, "sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA=="],
1757
1758 "expo-paste-input": ["expo-paste-input@0.1.15", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-wV0z1s0/Q4MB+lNiSwbMfck+1Im6sDIcgyCi5Y0aF35n5kcM4FHSOjmRfzn3UbkMlvxmYfDiHeHvIqn22qatqA=="],
1759
1760 "expo-router": ["expo-router@56.2.7", "", { "dependencies": { "@expo/log-box": "^56.0.12", "@expo/metro-runtime": "^56.0.13", "@expo/schema-utils": "^56.0.0", "@expo/ui": "^56.0.14", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^56.0.4", "expo-server": "^56.0.4", "expo-symbols": "^56.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.25.2", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.16", "expo-linking": "^56.0.12", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-T7MSugHfj6XDrVJG8dCkP5EEAWeCkPrkkxqKCqCRokXmBKTAiRGXsmPsgHzOXhr/5MxGDJXhj5ON19uWoCevDA=="],
1761
1762 "expo-secure-store": ["expo-secure-store@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ=="],
1763
1764 "expo-server": ["expo-server@56.0.4", "", {}, "sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA=="],
1765
1766 "expo-splash-screen": ["expo-splash-screen@56.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/image-utils": "^0.10.1", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA=="],
1767
1768 "expo-structured-headers": ["expo-structured-headers@56.0.0", "", {}, "sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw=="],
1769
1770 "expo-symbols": ["expo-symbols@56.0.5", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw=="],
1771
1772 "expo-updates": ["expo-updates@56.0.17", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/plist": "^0.7.0", "@expo/spawn-async": "^1.8.0", "arg": "^4.1.0", "chalk": "^4.1.2", "debug": "^4.3.4", "expo-eas-client": "~56.0.0", "expo-manifests": "~56.0.4", "expo-structured-headers": "~56.0.0", "expo-updates-interface": "~56.0.1", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "nullthrows": "^1.1.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "expo-dev-client": "*", "react": "*", "react-native": "*" }, "optionalPeers": ["expo-dev-client"], "bin": { "expo-updates": "bin/cli.js" } }, "sha512-4005IRQQ7Vpwb01X3NSG0SwY61HTJzT2pZ0OrkeZ6guQE1WKiRfUKisekRgYXTI8sPbxSHUc9aOpIx6yC49XZQ=="],
1773
1774 "expo-updates-interface": ["expo-updates-interface@56.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g=="],
1775
1776 "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
1777
1250 "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],1778 "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
12511779
1252 "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],1780 "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
...@@ -1263,25 +1791,37 @@...@@ -1263,25 +1791,37 @@
12631791
1264 "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],1792 "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
12651793
1794 "fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="],
1795
1796 "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="],
1797
1266 "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],1798 "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
12671799
1268 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],1800 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
12691801
1802 "fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="],
1803
1270 "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],1804 "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
12711805
1272 "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],1806 "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
1807
1808 "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="],
12731809
1274 "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],1810 "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
12751811
1276 "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],1812 "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
12771813
1814 "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="],
1815
1278 "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],1816 "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
12791817
1818 "fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="],
1819
1280 "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="],1820 "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="],
12811821
1282 "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],1822 "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
12831823
1284 "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],1824 "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
12851825
1286 "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],1826 "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
12871827
...@@ -1295,14 +1835,20 @@...@@ -1295,14 +1835,20 @@
12951835
1296 "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],1836 "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
12971837
1838 "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
1839
1298 "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],1840 "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
12991841
1300 "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],1842 "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
13011843
1302 "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],1844 "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
13031845
1846 "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
1847
1304 "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],1848 "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
13051849
1850 "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
1851
1306 "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],1852 "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
13071853
1308 "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],1854 "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
...@@ -1319,11 +1865,13 @@...@@ -1319,11 +1865,13 @@
13191865
1320 "h3": ["h3@1.15.6", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ=="],1866 "h3": ["h3@1.15.6", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ=="],
13211867
1868 "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
1869
1322 "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],1870 "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
13231871
1324 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],1872 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
13251873
1326 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],1874 "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
13271875
1328 "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],1876 "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
13291877
...@@ -1349,10 +1897,20 @@...@@ -1349,10 +1897,20 @@
13491897
1350 "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],1898 "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
13511899
1352 "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="],1900 "hermes-compiler": ["hermes-compiler@250829098.0.10", "", {}, "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w=="],
1901
1902 "hermes-estree": ["hermes-estree@0.33.3", "", {}, "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg=="],
1903
1904 "hermes-parser": ["hermes-parser@0.33.3", "", { "dependencies": { "hermes-estree": "0.33.3" } }, "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA=="],
1905
1906 "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
1907
1908 "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="],
13531909
1354 "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],1910 "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
13551911
1912 "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
1913
1356 "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],1914 "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
13571915
1358 "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],1916 "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
...@@ -1365,16 +1923,26 @@...@@ -1365,16 +1923,26 @@
13651923
1366 "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],1924 "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
13671925
1926 "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
1927
1368 "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],1928 "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
13691929
1930 "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
1931
1932 "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="],
1933
1370 "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="],1934 "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="],
13711935
1936 "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
1937
1372 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],1938 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
13731939
1374 "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],1940 "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
13751941
1376 "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],1942 "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
13771943
1944 "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
1945
1378 "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="],1946 "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="],
13791947
1380 "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],1948 "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
...@@ -1387,8 +1955,12 @@...@@ -1387,8 +1955,12 @@
13871955
1388 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],1956 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
13891957
1958 "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
1959
1390 "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],1960 "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
13911961
1962 "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
1963
1392 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],1964 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
13931965
1394 "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],1966 "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
...@@ -1419,6 +1991,16 @@...@@ -1419,6 +1991,16 @@
14191991
1420 "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],1992 "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
14211993
1994 "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="],
1995
1996 "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
1997
1998 "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="],
1999
2000 "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="],
2001
2002 "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="],
2003
1422 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],2004 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
14232005
1424 "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],2006 "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
...@@ -1427,6 +2009,8 @@...@@ -1427,6 +2009,8 @@
14272009
1428 "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],2010 "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
14292011
2012 "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="],
2013
1430 "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],2014 "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
14312015
1432 "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],2016 "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
...@@ -1451,35 +2035,43 @@...@@ -1451,35 +2035,43 @@
14512035
1452 "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],2036 "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
14532037
2038 "lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="],
2039
1454 "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],2040 "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
14552041
2042 "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
2043
1456 "lexical": ["lexical@0.41.0", "", {}, "sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA=="],2044 "lexical": ["lexical@0.41.0", "", {}, "sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA=="],
14572045
1458 "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],2046 "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
14592047
1460 "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],2048 "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],
2049
2050 "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
14612051
1462 "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],2052 "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
14632053
1464 "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],2054 "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
14652055
1466 "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],2056 "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
14672057
1468 "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],2058 "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
14692059
1470 "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],2060 "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
14712061
1472 "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],2062 "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
14732063
1474 "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],2064 "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
14752065
1476 "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],2066 "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
14772067
1478 "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],2068 "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
14792069
1480 "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],2070 "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
14812071
1482 "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],2072 "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
2073
2074 "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
14832075
1484 "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],2076 "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
14852077
...@@ -1489,8 +2081,14 @@...@@ -1489,8 +2081,14 @@
14892081
1490 "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],2082 "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
14912083
2084 "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
2085
2086 "log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="],
2087
1492 "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],2088 "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
14932089
2090 "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
2091
1494 "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],2092 "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
14952093
1496 "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],2094 "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
...@@ -1499,12 +2097,18 @@...@@ -1499,12 +2097,18 @@
14992097
1500 "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],2098 "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
15012099
2100 "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
2101
1502 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],2102 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
15032103
1504 "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="],2104 "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="],
15052105
2106 "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
2107
1506 "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],2108 "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
15072109
2110 "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
2111
1508 "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],2112 "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
15092113
1510 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],2114 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
...@@ -1541,12 +2145,44 @@...@@ -1541,12 +2145,44 @@
15412145
1542 "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],2146 "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
15432147
1544 "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],2148 "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
15452149
1546 "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],2150 "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
15472151
2152 "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
2153
1548 "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],2154 "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
15492155
2156 "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
2157
2158 "metro": ["metro@0.84.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA=="],
2159
2160 "metro-babel-transformer": ["metro-babel-transformer@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g=="],
2161
2162 "metro-cache": ["metro-cache@0.84.4", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.84.4" } }, "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg=="],
2163
2164 "metro-cache-key": ["metro-cache-key@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw=="],
2165
2166 "metro-config": ["metro-config@0.84.4", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.84.4", "metro-cache": "0.84.4", "metro-core": "0.84.4", "metro-runtime": "0.84.4", "yaml": "^2.6.1" } }, "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q=="],
2167
2168 "metro-core": ["metro-core@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.4" } }, "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ=="],
2169
2170 "metro-file-map": ["metro-file-map@0.84.4", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ=="],
2171
2172 "metro-minify-terser": ["metro-minify-terser@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ=="],
2173
2174 "metro-resolver": ["metro-resolver@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A=="],
2175
2176 "metro-runtime": ["metro-runtime@0.84.4", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q=="],
2177
2178 "metro-source-map": ["metro-source-map@0.84.4", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.84.4", "nullthrows": "^1.1.1", "ob1": "0.84.4", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g=="],
2179
2180 "metro-symbolicate": ["metro-symbolicate@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.84.4", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA=="],
2181
2182 "metro-transform-plugins": ["metro-transform-plugins@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow=="],
2183
2184 "metro-transform-worker": ["metro-transform-worker@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-minify-terser": "0.84.4", "metro-source-map": "0.84.4", "metro-transform-plugins": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg=="],
2185
1550 "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],2186 "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
15512187
1552 "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],2188 "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
...@@ -1603,14 +2239,26 @@...@@ -1603,14 +2239,26 @@
16032239
1604 "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],2240 "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
16052241
2242 "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
2243
1606 "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],2244 "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
16072245
1608 "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],2246 "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
16092247
1610 "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],2248 "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
2249
2250 "mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
16112251
1612 "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],2252 "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
16132253
2254 "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
2255
2256 "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
2257
2258 "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
2259
2260 "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
2261
1614 "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],2262 "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
16152263
1616 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],2264 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
...@@ -1625,11 +2273,13 @@...@@ -1625,11 +2273,13 @@
16252273
1626 "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],2274 "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
16272275
2276 "multitars": ["multitars@1.0.0", "", {}, "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg=="],
2277
1628 "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],2278 "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
16292279
1630 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],2280 "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
16312281
1632 "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],2282 "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
16332283
1634 "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],2284 "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],
16352285
...@@ -1639,8 +2289,12 @@...@@ -1639,8 +2289,12 @@
16392289
1640 "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],2290 "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
16412291
2292 "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
2293
1642 "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],2294 "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
16432295
2296 "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
2297
1644 "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],2298 "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
16452299
1646 "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],2300 "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
...@@ -1651,8 +2305,14 @@...@@ -1651,8 +2305,14 @@
16512305
1652 "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],2306 "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
16532307
2308 "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
2309
1654 "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],2310 "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
16552311
2312 "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
2313
2314 "ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="],
2315
1656 "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],2316 "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
16572317
1658 "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],2318 "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
...@@ -1667,12 +2327,20 @@...@@ -1667,12 +2327,20 @@
16672327
1668 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],2328 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
16692329
2330 "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
2331
1670 "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],2332 "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
16712333
2334 "onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="],
2335
1672 "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],2336 "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
16732337
1674 "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],2338 "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
16752339
2340 "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
2341
2342 "ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
2343
1676 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],2344 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
16772345
1678 "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="],2346 "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="],
...@@ -1693,6 +2361,8 @@...@@ -1693,6 +2361,8 @@
16932361
1694 "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],2362 "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
16952363
2364 "parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="],
2365
1696 "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],2366 "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
16972367
1698 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],2368 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
...@@ -1701,6 +2371,10 @@...@@ -1701,6 +2371,10 @@
17012371
1702 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],2372 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
17032373
2374 "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
2375
2376 "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
2377
1704 "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],2378 "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
17052379
1706 "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],2380 "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
...@@ -1721,6 +2395,8 @@...@@ -1721,6 +2395,8 @@
17212395
1722 "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],2396 "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
17232397
2398 "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="],
2399
1724 "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],2400 "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
17252401
1726 "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],2402 "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
...@@ -1729,10 +2405,18 @@...@@ -1729,10 +2405,18 @@
17292405
1730 "pretty-cache-header": ["pretty-cache-header@1.0.0", "", { "dependencies": { "timestring": "^6.0.0" } }, "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw=="],2406 "pretty-cache-header": ["pretty-cache-header@1.0.0", "", { "dependencies": { "timestring": "^6.0.0" } }, "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw=="],
17312407
2408 "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
2409
1732 "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],2410 "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
17332411
2412 "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
2413
1734 "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],2414 "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
17352415
2416 "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="],
2417
2418 "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
2419
1736 "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],2420 "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
17372421
1738 "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],2422 "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
...@@ -1747,6 +2431,10 @@...@@ -1747,6 +2431,10 @@
17472431
1748 "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],2432 "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
17492433
2434 "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
2435
2436 "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],
2437
1750 "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],2438 "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
17512439
1752 "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],2440 "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
...@@ -1755,28 +2443,86 @@...@@ -1755,28 +2443,86 @@
17552443
1756 "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],2444 "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
17572445
1758 "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],2446 "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
17592447
1760 "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],2448 "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
2449
2450 "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
17612451
1762 "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="],2452 "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="],
17632453
2454 "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],
2455
2456 "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
2457
2458 "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="],
2459
1764 "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],2460 "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
17652461
2462 "react-native": ["react-native@0.85.3", "", { "dependencies": { "@react-native/assets-registry": "0.85.3", "@react-native/codegen": "0.85.3", "@react-native/community-cli-plugin": "0.85.3", "@react-native/gradle-plugin": "0.85.3", "@react-native/js-polyfills": "0.85.3", "@react-native/normalize-colors": "0.85.3", "@react-native/virtualized-lists": "0.85.3", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.33.3", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.10", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.85.3", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA=="],
2463
2464 "react-native-drawer-layout": ["react-native-drawer-layout@4.2.4", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", "react-native-reanimated": ">= 2.0.0" } }, "sha512-l1Le5HcVidobnJm8xqFZo46Rs8FDHdxbTZhkjxpNSRgU+QMoQXilOfzTHAeNjEGiKVGgIs9cW3ctXeHqgp5jJg=="],
2465
2466 "react-native-gesture-handler": ["react-native-gesture-handler@2.31.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A=="],
2467
2468 "react-native-image-viewing": ["react-native-image-viewing@0.2.2", "", { "peerDependencies": { "react": ">=16.11.0", "react-native": ">=0.61.3" } }, "sha512-osWieG+p/d2NPbAyonOMubttajtYEYiRGQaJA54slFxZ69j1V4/dCmcrVQry47ktVKy8/qpFwCpW1eT6MH5T2Q=="],
2469
2470 "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="],
2471
2472 "react-native-keyboard-controller": ["react-native-keyboard-controller@1.21.6", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-reanimated": ">=3.0.0" } }, "sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw=="],
2473
2474 "react-native-nitro-markdown": ["react-native-nitro-markdown@0.5.8", "", { "peerDependencies": { "react": "*", "react-native": ">=0.75.0", "react-native-mathjax-svg": ">=0.9.0", "react-native-nitro-modules": ">=0.35.5", "react-native-svg": ">=13.0.0" }, "optionalPeers": ["react-native-mathjax-svg", "react-native-svg"] }, "sha512-bBKmdWgMqZoNozuHzJgc8VxWRSjLnrHAK0bRRvz7gj5FqPxLJz+KhRV0VYZDZgvsR3S8hnmmeEqluTRBTRw+yA=="],
2475
2476 "react-native-nitro-modules": ["react-native-nitro-modules@0.35.7", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-3EiU27EmnxTlD3pAXR2OBWeDg7+9tdjKrNQOPX08rFX5hJPNIT/h1i2PjvTUieOypCGTEi9nW/SNBtK3F+4HYg=="],
2477
2478 "react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="],
2479
2480 "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="],
2481
2482 "react-native-screens": ["react-native-screens@4.25.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.82.0" } }, "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg=="],
2483
2484 "react-native-shiki-engine": ["react-native-shiki-engine@0.3.10", "", { "dependencies": { "@shikijs/types": "^3.19.0", "@shikijs/vscode-textmate": "^10.0.2" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-gaEFPbd3//nFCApZOj1c+4Q69c1aqFYYn0v22YRW4jRVerf2MmcAWXYnRnmkJIxJ6hXy8U2JsBEAX6HhsCipzA=="],
2485
2486 "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
2487
2488 "react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="],
2489
2490 "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
2491
2492 "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
2493
2494 "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
2495
2496 "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
2497
1766 "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],2498 "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
17672499
1768 "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],2500 "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
17692501
2502 "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
2503
1770 "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],2504 "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
17712505
1772 "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],2506 "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
17732507
2508 "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
2509
2510 "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="],
2511
2512 "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
2513
1774 "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],2514 "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
17752515
1776 "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],2516 "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
17772517
1778 "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],2518 "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
17792519
2520 "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="],
2521
2522 "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="],
2523
2524 "regjsparser": ["regjsparser@0.13.1", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw=="],
2525
1780 "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],2526 "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
17812527
1782 "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],2528 "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
...@@ -1803,12 +2549,20 @@...@@ -1803,12 +2549,20 @@
18032549
1804 "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],2550 "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
18052551
2552 "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
2553
1806 "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],2554 "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
18072555
2556 "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
2557
1808 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],2558 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
18092559
2560 "resolve-workspace-root": ["resolve-workspace-root@2.0.1", "", {}, "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w=="],
2561
1810 "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],2562 "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
18112563
2564 "restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="],
2565
1812 "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],2566 "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
18132567
1814 "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="],2568 "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="],
...@@ -1827,6 +2581,8 @@...@@ -1827,6 +2581,8 @@
18272581
1828 "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],2582 "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
18292583
2584 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
2585
1830 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],2586 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
18312587
1832 "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],2588 "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
...@@ -1837,7 +2593,7 @@...@@ -1837,7 +2593,7 @@
18372593
1838 "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],2594 "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
18392595
1840 "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],2596 "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
18412597
1842 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],2598 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
18432599
...@@ -1845,17 +2601,25 @@...@@ -1845,17 +2601,25 @@
18452601
1846 "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],2602 "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
18472603
1848 "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],2604 "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
2605
2606 "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
18492607
1850 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],2608 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
18512609
2610 "sf-symbols-typescript": ["sf-symbols-typescript@2.2.0", "", {}, "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw=="],
2611
2612 "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
2613
1852 "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],2614 "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
18532615
1854 "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],2616 "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
18552617
1856 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],2618 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
18572619
1858 "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],2620 "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
2621
2622 "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
18592623
1860 "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],2624 "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
18612625
...@@ -1869,42 +2633,70 @@...@@ -1869,42 +2633,70 @@
18692633
1870 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],2634 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
18712635
2636 "simple-plist": ["simple-plist@1.3.1", "", { "dependencies": { "bplist-creator": "0.1.0", "bplist-parser": "0.3.1", "plist": "^3.0.5" } }, "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw=="],
2637
2638 "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
2639
1872 "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],2640 "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
18732641
1874 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],2642 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
18752643
2644 "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="],
2645
1876 "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],2646 "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],
18772647
1878 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],2648 "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
18792649
1880 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],2650 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
18812651
2652 "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
2653
1882 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],2654 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
18832655
2656 "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
2657
1884 "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],2658 "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
18852659
1886 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],2660 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
18872661
2662 "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
2663
2664 "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="],
2665
1888 "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],2666 "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
18892667
1890 "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],2668 "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
18912669
1892 "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],2670 "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
18932671
2672 "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="],
2673
1894 "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],2674 "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
18952675
2676 "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
2677
1896 "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],2678 "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
18972679
1898 "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],2680 "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
18992681
1900 "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],2682 "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
19012683
2684 "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
2685
2686 "structured-headers": ["structured-headers@0.4.1", "", {}, "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="],
2687
1902 "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],2688 "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
19032689
1904 "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],2690 "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
19052691
1906 "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],2692 "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
19072693
2694 "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
2695
2696 "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="],
2697
2698 "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
2699
1908 "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="],2700 "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="],
19092701
1910 "t3": ["t3@workspace:apps/server"],2702 "t3": ["t3@workspace:apps/server"],
...@@ -1919,6 +2711,12 @@...@@ -1919,6 +2711,12 @@
19192711
1920 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],2712 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
19212713
2714 "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
2715
2716 "terser": ["terser@5.47.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw=="],
2717
2718 "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="],
2719
1922 "timestring": ["timestring@6.0.0", "", {}, "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA=="],2720 "timestring": ["timestring@6.0.0", "", {}, "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA=="],
19232721
1924 "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],2722 "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
...@@ -1945,12 +2743,16 @@...@@ -1945,12 +2743,16 @@
19452743
1946 "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="],2744 "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="],
19472745
2746 "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
2747
1948 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],2748 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
19492749
1950 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],2750 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
19512751
1952 "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],2752 "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
19532753
2754 "toqr": ["toqr@0.1.1", "", {}, "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA=="],
2755
1954 "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],2756 "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
19552757
1956 "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],2758 "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
...@@ -2007,6 +2809,14 @@...@@ -2007,6 +2809,14 @@
20072809
2008 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],2810 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
20092811
2812 "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
2813
2814 "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="],
2815
2816 "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="],
2817
2818 "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="],
2819
2010 "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],2820 "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
20112821
2012 "unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],2822 "unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
...@@ -2031,6 +2841,8 @@...@@ -2031,6 +2841,8 @@
20312841
2032 "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],2842 "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
20332843
2844 "uniwind": ["uniwind@1.6.5", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "culori": "4.0.2", "lightningcss": "1.30.1" }, "peerDependencies": { "react": ">=19.0.0", "react-native": ">=0.81.0", "tailwindcss": ">=4" } }, "sha512-KjOtZzC+YW0I7+TogyLFGexsKCy2A3ZRVS70Cs5Exs+wyY2ll7Zd22nXywr1w/Jo0eryCie+ekX9ZPdZheb1kw=="],
2845
2034 "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],2846 "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
20352847
2036 "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],2848 "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
...@@ -2045,12 +2857,24 @@...@@ -2045,12 +2857,24 @@
20452857
2046 "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],2858 "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
20472859
2860 "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
2861
2862 "use-latest-callback": ["use-latest-callback@0.2.6", "", { "peerDependencies": { "react": ">=16.8" } }, "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg=="],
2863
2864 "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
2865
2048 "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],2866 "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
20492867
2868 "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
2869
2050 "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],2870 "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
20512871
2872 "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
2873
2052 "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],2874 "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
20532875
2876 "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
2877
2054 "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],2878 "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
20552879
2056 "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],2880 "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
...@@ -2065,6 +2889,8 @@...@@ -2065,6 +2889,8 @@
20652889
2066 "vitest-browser-react": ["vitest-browser-react@2.1.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/cOVQ+dZojhavfsbHjcfzB3zrUxG39HIbGdvK9vSBdGc8b8HRu5Bql0p8aXtKw4sb8/E8n5XEncQxvqHtfjjag=="],2890 "vitest-browser-react": ["vitest-browser-react@2.1.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/cOVQ+dZojhavfsbHjcfzB3zrUxG39HIbGdvK9vSBdGc8b8HRu5Bql0p8aXtKw4sb8/E8n5XEncQxvqHtfjjag=="],
20672891
2892 "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="],
2893
2068 "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="],2894 "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="],
20692895
2070 "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="],2896 "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="],
...@@ -2099,10 +2925,20 @@...@@ -2099,10 +2925,20 @@
20992925
2100 "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],2926 "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
21012927
2928 "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
2929
2930 "warn-once": ["warn-once@0.1.1", "", {}, "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q=="],
2931
2932 "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
2933
2102 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],2934 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
21032935
2104 "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],2936 "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
21052937
2938 "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
2939
2940 "whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="],
2941
2106 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],2942 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
21072943
2108 "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],2944 "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
...@@ -2113,7 +2949,13 @@...@@ -2113,7 +2949,13 @@
21132949
2114 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],2950 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
21152951
2116 "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],2952 "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
2953
2954 "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
2955
2956 "xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
2957
2958 "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
21172959
2118 "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],2960 "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
21192961
...@@ -2145,49 +2987,85 @@...@@ -2145,49 +2987,85 @@
21452987
2146 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],2988 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
21472989
2990 "zxing-wasm": ["zxing-wasm@3.0.3", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.6.0" } }, "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ=="],
2991
2148 "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],2992 "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],
21492993
2994 "@astrojs/markdown-remark/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
2995
2150 "@astrojs/yaml2ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],2996 "@astrojs/yaml2ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
21512997
2152 "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],2998 "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
21532999
2154 "@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
2155
2156 "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],3000 "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
21573001
2158 "@babel/generator/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="],
2159
2160 "@babel/generator/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="],
2161
2162 "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],3002 "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
21633003
2164 "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],3004 "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
21653005
3006 "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
3007
3008 "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
3009
2166 "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],3010 "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
21673011
2168 "@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],3012 "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
21693013
2170 "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],3014 "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
21713015
2172 "@base-ui/react/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],3016 "@effect/atom-react/react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
21733017
2174 "@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],3018 "@effect/platform-node-shared/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
21753019
2176 "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],3020 "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
21773021
2178 "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],3022 "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
21793023
3024 "@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
3025
3026 "@expo/cli/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
3027
3028 "@expo/cli/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
3029
3030 "@expo/cli/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
3031
3032 "@expo/cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
3033
3034 "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
3035
3036 "@expo/fingerprint/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
3037
3038 "@expo/metro-config/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
3039
3040 "@expo/metro-config/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
3041
3042 "@expo/metro-config/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
3043
3044 "@expo/metro-runtime/@expo/log-box": ["@expo/log-box@55.0.12", "", { "dependencies": { "@expo/dom-webview": "^55.0.6", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-f9ARS8J60cq3LLNdIqmUjYwyerBzVS5Ecp7KjIf3GOIPjW0571rkcwLz4/U18l/1DeSkSzIkYsNl2TC9oTdWaQ=="],
3045
3046 "@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
3047
2180 "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],3048 "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
21813049
2182 "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],3050 "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
3051
3052 "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
3053
3054 "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
21833055
2184 "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],3056 "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
21853057
2186 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],3058 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
21873059
2188 "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],3060 "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
3061
3062 "@t3tools/mobile/@t3tools/mobile-review-diff-native": ["@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff", {}],
3063
3064 "@t3tools/mobile/@t3tools/mobile-terminal-native": ["@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal", {}],
21893065
2190 "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],3066 "@t3tools/web/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
3067
3068 "@t3tools/web/react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
21913069
2192 "@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],3070 "@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
21933071
...@@ -2209,13 +3087,19 @@...@@ -2209,13 +3087,19 @@
22093087
2210 "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],3088 "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
22113089
3090 "@tanstack/router-generator/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
3091
2212 "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],3092 "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
22133093
2214 "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],3094 "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
22153095
2216 "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],3096 "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
22173097
2218 "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],3098 "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
3099
3100 "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
3101
3102 "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
22193103
2220 "@vercel/config/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],3104 "@vercel/config/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
22213105
...@@ -2225,73 +3109,225 @@...@@ -2225,73 +3109,225 @@
22253109
2226 "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],3110 "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
22273111
3112 "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
3113
2228 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],3114 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
22293115
2230 "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="],3116 "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="],
22313117
3118 "astro/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
3119
3120 "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
3121
3122 "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
3123
3124 "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
3125
3126 "chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
3127
2232 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],3128 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
22333129
3130 "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
3131
3132 "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
3133
3134 "compression/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
3135
3136 "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
3137
3138 "css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
3139
2234 "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],3140 "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
22353141
2236 "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],3142 "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
22373143
3144 "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
3145
3146 "expo-router/@expo/metro-runtime": ["@expo/metro-runtime@56.0.13", "", { "dependencies": { "@expo/log-box": "^56.0.12", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-aMaFa/RPYm2iQoyYOB5q8AxDmWvf4E2yFbZ6rmBIQWaIPDdixGVUlLQeV8DlDAfZ/j+pNYO7l5M+774WbgkTgg=="],
3147
3148 "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
3149
2238 "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],3150 "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
22393151
3152 "express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
3153
3154 "express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
3155
3156 "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
3157
3158 "express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
3159
3160 "express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
3161
3162 "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
3163
3164 "finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
3165
3166 "finalhandler/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="],
3167
3168 "finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="],
3169
3170 "get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
3171
2240 "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],3172 "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
22413173
3174 "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
3175
3176 "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
3177
3178 "jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
3179
3180 "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
3181
3182 "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
3183
3184 "json-schema-to-ts/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
3185
3186 "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
3187
3188 "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
3189
2242 "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],3190 "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
22433191
3192 "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
3193
3194 "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
3195
3196 "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="],
3197
3198 "metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
3199
3200 "metro/serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="],
3201
3202 "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="],
3203
3204 "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
3205
3206 "open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
3207
3208 "open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
3209
3210 "ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
3211
3212 "ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
3213
2244 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],3214 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
22453215
3216 "parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
3217
3218 "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
3219
3220 "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
3221
3222 "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
3223
3224 "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
3225
3226 "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
3227
2246 "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],3228 "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
22473229
3230 "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
3231
2248 "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],3232 "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
22493233
3234 "rolldown-plugin-dts/@babel/generator": ["@babel/generator@8.0.0-rc.2", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.2", "@babel/types": "^8.0.0-rc.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ=="],
3235
2250 "rolldown-plugin-dts/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="],3236 "rolldown-plugin-dts/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="],
22513237
2252 "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="],3238 "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="],
22533239
2254 "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],3240 "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
22553241
3242 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
3243
3244 "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
3245
2256 "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],3246 "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
22573247
3248 "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
3249
3250 "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
3251
3252 "stacktrace-parser/type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
3253
3254 "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
3255
3256 "svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
3257
2258 "t3/@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="],3258 "t3/@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="],
22593259
3260 "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
3261
2260 "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],3262 "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
22613263
3264 "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
3265
3266 "unifont/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
3267
2262 "unrun/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],3268 "unrun/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
22633269
2264 "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],3270 "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
22653271
2266 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],3272 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
22673273
3274 "vite/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
3275
2268 "vite/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],3276 "vite/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
22693277
2270 "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],3278 "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
22713279
3280 "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
3281
3282 "xcode/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="],
3283
2272 "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],3284 "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
22733285
2274 "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],3286 "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
22753287
2276 "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],3288 "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
22773289
2278 "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="],3290 "zxing-wasm/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
3291
3292 "@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
3293
3294 "@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
3295
3296 "@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
3297
3298 "@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
3299
3300 "@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
3301
3302 "@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
22793303
2280 "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],3304 "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
22813305
2282 "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],3306 "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
22833307
2284 "@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],3308 "@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
3309
3310 "@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
3311
3312 "@expo/metro-config/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
3313
3314 "@expo/metro-config/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
3315
3316 "@expo/metro-config/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
3317
3318 "@expo/metro-config/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
3319
3320 "@expo/metro-config/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
22853321
2286 "@pierre/diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],3322 "@expo/metro-config/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
22873323
2288 "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],3324 "@expo/metro-config/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
22893325
2290 "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],3326 "@expo/metro-config/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
22913327
2292 "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],3328 "@expo/metro-runtime/@expo/log-box/@expo/dom-webview": ["@expo/dom-webview@55.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZNm8tiNEZysxrr36J0x4mOCGyJDcaIvL/3tMxBz0VJIJDcV19xjuJAhJQxHovu+jKx6s9tRyEAINa1mdrzV39g=="],
22933329
2294 "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],3330 "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
22953331
2296 "@rolldown/plugin-babel/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],3332 "@rolldown/plugin-babel/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
22973333
...@@ -2349,14 +3385,74 @@...@@ -2349,14 +3385,74 @@
23493385
2350 "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],3386 "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
23513387
3388 "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
3389
2352 "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],3390 "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
23533391
2354 "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="],3392 "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="],
23553393
3394 "astro/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
3395
3396 "astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
3397
3398 "astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
3399
3400 "astro/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
3401
3402 "astro/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
3403
3404 "astro/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
3405
3406 "chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
3407
3408 "chromium-edge-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
3409
3410 "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
3411
3412 "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
3413
2356 "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],3414 "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
23573415
3416 "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
3417
3418 "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
3419
3420 "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
3421
3422 "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
3423
3424 "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
3425
3426 "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
3427
3428 "log-symbols/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
3429
3430 "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="],
3431
3432 "metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
3433
3434 "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="],
3435
3436 "metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
3437
3438 "ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
3439
3440 "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
3441
3442 "ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
3443
3444 "ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
3445
2358 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="],3446 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="],
23593447
3448 "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
3449
3450 "svgo/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
3451
3452 "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
3453
3454 "unifont/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
3455
2360 "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],3456 "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
23613457
2362 "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],3458 "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
...@@ -2389,6 +3485,26 @@...@@ -2389,6 +3485,26 @@
23893485
2390 "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],3486 "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
23913487
3488 "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
3489
3490 "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
3491
3492 "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
3493
3494 "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
3495
3496 "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
3497
3498 "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
3499
3500 "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
3501
3502 "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
3503
3504 "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
3505
3506 "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
3507
2392 "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],3508 "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
23933509
2394 "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],3510 "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
...@@ -2422,5 +3538,17 @@...@@ -2422,5 +3538,17 @@
2422 "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],3538 "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
24233539
2424 "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="],3540 "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="],
3541
3542 "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
3543
3544 "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
3545
3546 "ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
3547
3548 "ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
3549
3550 "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
3551
3552 "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
2425 }3553 }
2426}3554}
package.json+4-1
...@@ -44,6 +44,7 @@...@@ -44,6 +44,7 @@
44 "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3",44 "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3",
45 "typecheck": "turbo run typecheck",45 "typecheck": "turbo run typecheck",
46 "lint": "oxlint --report-unused-disable-directives",46 "lint": "oxlint --report-unused-disable-directives",
47 "lint:mobile": "node scripts/mobile-native-static-check.ts",
47 "test": "turbo run test",48 "test": "turbo run test",
48 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",49 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
49 "fmt": "oxfmt",50 "fmt": "oxfmt",
...@@ -92,7 +93,9 @@...@@ -92,7 +93,9 @@
92 ]93 ]
93 },94 },
94 "patchedDependencies": {95 "patchedDependencies": {
95 "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch"96 "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch",
97 "react-native-nitro-modules@0.35.4": "patches/react-native-nitro-modules@0.35.4.patch",
98 "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch"
96 },99 },
97 "trustedDependencies": [100 "trustedDependencies": [
98 "node-pty",101 "node-pty",
packages/client-runtime/package.json+7-1
...@@ -2,10 +2,15 @@...@@ -2,10 +2,15 @@
2 "name": "@t3tools/client-runtime",2 "name": "@t3tools/client-runtime",
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "main": "./src/index.ts",
6 "types": "./src/index.ts",
5 "exports": {7 "exports": {
6 ".": {8 ".": {
7 "types": "./src/index.ts",9 "types": "./src/index.ts",
8 "import": "./src/index.ts"10 "react-native": "./src/index.ts",
11 "import": "./src/index.ts",
12 "require": "./src/index.ts",
13 "default": "./src/index.ts"
9 }14 }
10 },15 },
11 "scripts": {16 "scripts": {
...@@ -18,6 +23,7 @@...@@ -18,6 +23,7 @@
18 "effect": "catalog:"23 "effect": "catalog:"
19 },24 },
20 "devDependencies": {25 "devDependencies": {
26 "@effect/vitest": "catalog:",
21 "vitest": "catalog:"27 "vitest": "catalog:"
22 }28 }
23}29}
packages/client-runtime/src/addProject.test.ts created+146
...@@ -0,0 +1,146 @@
1import { describe, expect, it } from "vitest";
2import {
3 EnvironmentId,
4 ProjectId,
5 CommandId,
6 SourceControlDiscoveryResult,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9
10import {
11 buildAddProjectRemoteSourceReadiness,
12 buildProjectCreateCommand,
13 findExistingAddProject,
14 getAddProjectInitialQuery,
15 resolveAddProjectPath,
16 sortAddProjectProviderSources,
17} from "./addProject.ts";
18import type { EnvironmentScopedProjectShell } from "./shellTypes.ts";
19
20describe("add project shared logic", () => {
21 it("resolves initial browse paths from settings", () => {
22 expect(getAddProjectInitialQuery("")).toBe("~/");
23 expect(getAddProjectInitialQuery("/work")).toBe("/work/");
24 expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\");
25 });
26
27 it("rejects unsupported windows paths on non-windows environments", () => {
28 expect(
29 resolveAddProjectPath({
30 rawPath: "C:\\repo",
31 platform: "MacIntel",
32 currentProjectCwd: null,
33 }),
34 ).toEqual({
35 ok: false,
36 error: "Windows-style paths are only supported on Windows environments.",
37 });
38 });
39
40 it("resolves relative paths from the active project cwd", () => {
41 expect(
42 resolveAddProjectPath({
43 rawPath: "../next",
44 platform: "Linux",
45 currentProjectCwd: "/work/current",
46 }),
47 ).toEqual({ ok: true, path: "/work/next" });
48 });
49
50 it("marks authenticated source control providers as ready", () => {
51 const discovery: SourceControlDiscoveryResult = {
52 versionControlSystems: [],
53 sourceControlProviders: [
54 {
55 kind: "github",
56 label: "GitHub",
57 status: "available",
58 installHint: "Install gh",
59 version: Option.some("1.0.0"),
60 detail: Option.none(),
61 auth: {
62 status: "authenticated",
63 account: Option.some("octo"),
64 host: Option.some("github.com"),
65 detail: Option.none(),
66 },
67 },
68 {
69 kind: "gitlab",
70 label: "GitLab",
71 status: "available",
72 installHint: "Install glab",
73 version: Option.some("1.0.0"),
74 detail: Option.none(),
75 auth: {
76 status: "unauthenticated",
77 account: Option.none(),
78 host: Option.none(),
79 detail: Option.some("Run glab auth login"),
80 },
81 },
82 ],
83 };
84
85 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
86 expect(readiness.url.ready).toBe(true);
87 expect(readiness.github.ready).toBe(true);
88 expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" });
89 expect(sortAddProjectProviderSources(readiness)[0]).toBe("github");
90 });
91
92 it("finds existing projects by normalized path in the target environment", () => {
93 const env = EnvironmentId.make("env");
94 const other = EnvironmentId.make("other");
95 const projects: EnvironmentScopedProjectShell[] = [
96 {
97 environmentId: other,
98 id: ProjectId.make("same-path-other-env"),
99 title: "Other",
100 workspaceRoot: "/repo",
101 createdAt: "2026-01-01T00:00:00.000Z",
102 updatedAt: "2026-01-01T00:00:00.000Z",
103 repositoryIdentity: null,
104 defaultModelSelection: null,
105 scripts: [],
106 },
107 {
108 environmentId: env,
109 id: ProjectId.make("project"),
110 title: "Repo",
111 workspaceRoot: "/repo/",
112 createdAt: "2026-01-01T00:00:00.000Z",
113 updatedAt: "2026-01-01T00:00:00.000Z",
114 repositoryIdentity: null,
115 defaultModelSelection: null,
116 scripts: [],
117 },
118 ];
119
120 expect(findExistingAddProject({ projects, environmentId: env, path: "/repo" })?.id).toBe(
121 "project",
122 );
123 });
124
125 it("builds the existing project.create command shape", () => {
126 expect(
127 buildProjectCreateCommand({
128 commandId: CommandId.make("command"),
129 projectId: ProjectId.make("project"),
130 workspaceRoot: "/work/repo",
131 createdAt: "2026-01-01T00:00:00.000Z",
132 }),
133 ).toMatchObject({
134 type: "project.create",
135 commandId: "command",
136 projectId: "project",
137 title: "repo",
138 workspaceRoot: "/work/repo",
139 createWorkspaceRootIfMissing: true,
140 defaultModelSelection: {
141 instanceId: "codex",
142 model: "gpt-5.4",
143 },
144 });
145 });
146});
packages/client-runtime/src/addProject.ts created+224
...@@ -0,0 +1,224 @@
1import type {
2 CommandId,
3 EnvironmentId,
4 OrchestrationCommand,
5 ProjectId,
6 SourceControlDiscoveryResult,
7 SourceControlProviderKind,
8 SourceControlRepositoryInfo,
9} from "@t3tools/contracts";
10import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts";
11import * as Arr from "effect/Array";
12import * as Option from "effect/Option";
13import * as Order from "effect/Order";
14
15import {
16 ensureBrowseDirectoryPath,
17 findProjectByPath,
18 inferProjectTitleFromPath,
19 isExplicitRelativeProjectPath,
20 isUnsupportedWindowsProjectPath,
21 resolveProjectPathForDispatch,
22} from "./projectPaths.ts";
23import type { EnvironmentScopedProjectShell } from "./shellTypes.ts";
24
25export type AddProjectRemoteProviderKind = Extract<
26 SourceControlProviderKind,
27 "github" | "gitlab" | "bitbucket" | "azure-devops"
28>;
29export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
30
31export type AddProjectRemoteSourceReadiness = Record<
32 AddProjectRemoteSource,
33 { readonly ready: boolean; readonly hint: string | null }
34>;
35
36export type AddProjectCloneFlow =
37 | {
38 readonly step: "repository";
39 readonly environmentId: EnvironmentId;
40 readonly source: AddProjectRemoteSource;
41 }
42 | {
43 readonly step: "confirm";
44 readonly environmentId: EnvironmentId;
45 readonly source: AddProjectRemoteSource;
46 readonly repositoryInput: string;
47 readonly repository: SourceControlRepositoryInfo | null;
48 readonly remoteUrl: string;
49 };
50
51export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
52 "url",
53 "github",
54 "gitlab",
55 "bitbucket",
56 "azure-devops",
57];
58
59export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
60 "github",
61 "gitlab",
62 "bitbucket",
63 "azure-devops",
64];
65
66export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string {
67 switch (source) {
68 case "github":
69 return "GitHub";
70 case "gitlab":
71 return "GitLab";
72 case "bitbucket":
73 return "Bitbucket";
74 case "azure-devops":
75 return "Azure DevOps";
76 case "url":
77 return "Git URL";
78 }
79}
80
81export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {
82 switch (source) {
83 case "github":
84 return "owner/repo";
85 case "gitlab":
86 return "group/project";
87 case "bitbucket":
88 return "workspace/repository";
89 case "azure-devops":
90 return "project/repository";
91 case "url":
92 return "URL";
93 }
94}
95
96export function addProjectRemoteSourceProvider(
97 source: AddProjectRemoteSource,
98): AddProjectRemoteProviderKind | null {
99 return source === "url" ? null : source;
100}
101
102export function sortAddProjectProviderSources(
103 readinessBySource: AddProjectRemoteSourceReadiness,
104): ReadonlyArray<AddProjectRemoteProviderKind> {
105 return Arr.sort(
106 ADD_PROJECT_REMOTE_PROVIDER_SOURCES,
107 Order.mapInput(
108 Order.Struct({
109 ready: Order.flip(Order.Boolean),
110 label: Order.String,
111 }),
112 (source: AddProjectRemoteProviderKind) => ({
113 ready: readinessBySource[source].ready,
114 label: addProjectRemoteSourceLabel(source),
115 }),
116 ),
117 );
118}
119
120export function buildAddProjectRemoteSourceReadiness(
121 discovery: SourceControlDiscoveryResult | null,
122): AddProjectRemoteSourceReadiness {
123 const unavailable = {
124 ready: false,
125 hint: "Provider status unavailable. Open Source Control settings and rescan.",
126 } as const;
127 const readiness: AddProjectRemoteSourceReadiness = {
128 url: { ready: true, hint: null },
129 github: unavailable,
130 gitlab: unavailable,
131 bitbucket: unavailable,
132 "azure-devops": unavailable,
133 };
134
135 if (!discovery) {
136 return readiness;
137 }
138
139 const providerByKind = new Map(
140 discovery.sourceControlProviders.map((provider) => [provider.kind, provider]),
141 );
142 for (const source of ADD_PROJECT_REMOTE_SOURCES) {
143 const kind = addProjectRemoteSourceProvider(source);
144 if (!kind) continue;
145 const provider = providerByKind.get(kind);
146 if (!provider) {
147 readiness[source] = unavailable;
148 continue;
149 }
150 if (provider.status !== "available") {
151 readiness[source] = { ready: false, hint: provider.installHint };
152 continue;
153 }
154 if (provider.auth.status === "unauthenticated") {
155 readiness[source] = {
156 ready: false,
157 hint:
158 Option.getOrNull(provider.auth.detail) ??
159 `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`,
160 };
161 continue;
162 }
163 readiness[source] = { ready: true, hint: null };
164 }
165 return readiness;
166}
167
168export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string {
169 const trimmed = baseDirectory?.trim() ?? "";
170 return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);
171}
172
173export function resolveAddProjectPath(input: {
174 readonly rawPath: string;
175 readonly currentProjectCwd?: string | null;
176 readonly platform: string;
177}): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly error: string } {
178 const rawPath = input.rawPath.trim();
179 if (rawPath.length === 0) {
180 return { ok: false, error: "Enter a project path." };
181 }
182 if (isUnsupportedWindowsProjectPath(rawPath, input.platform)) {
183 return { ok: false, error: "Windows-style paths are only supported on Windows environments." };
184 }
185 if (isExplicitRelativeProjectPath(rawPath) && !input.currentProjectCwd) {
186 return { ok: false, error: "Relative paths require an active project in this environment." };
187 }
188 const path = resolveProjectPathForDispatch(rawPath, input.currentProjectCwd);
189 return path.length === 0 ? { ok: false, error: "Enter a project path." } : { ok: true, path };
190}
191
192export function findExistingAddProject(input: {
193 readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>;
194 readonly environmentId: EnvironmentId;
195 readonly path: string;
196}): EnvironmentScopedProjectShell | null {
197 return (
198 findProjectByPath(
199 input.projects.filter((project) => project.environmentId === input.environmentId),
200 input.path,
201 ) ?? null
202 );
203}
204
205export function buildProjectCreateCommand(input: {
206 readonly commandId: CommandId;
207 readonly projectId: ProjectId;
208 readonly workspaceRoot: string;
209 readonly createdAt: string;
210}): Extract<OrchestrationCommand, { type: "project.create" }> {
211 return {
212 type: "project.create",
213 commandId: input.commandId,
214 projectId: input.projectId,
215 title: inferProjectTitleFromPath(input.workspaceRoot),
216 workspaceRoot: input.workspaceRoot,
217 createWorkspaceRootIfMissing: true,
218 defaultModelSelection: {
219 instanceId: ProviderInstanceId.make("codex"),
220 model: DEFAULT_MODEL,
221 },
222 createdAt: input.createdAt,
223 };
224}
packages/client-runtime/src/archivedThreadsState.test.ts created+96
...@@ -0,0 +1,96 @@
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vitest";
4
5import {
6 type ArchivedThreadsClient,
7 createArchivedThreadsManager,
8 makeArchivedThreadsEnvironmentKey,
9 parseArchivedThreadsEnvironmentKey,
10 readArchivedThreadsSnapshotState,
11} from "./archivedThreadsState.ts";
12
13let registry = AtomRegistry.make();
14
15function resetAtomRegistry() {
16 registry.dispose();
17 registry = AtomRegistry.make();
18}
19
20function createSnapshot(id: string): OrchestrationShellSnapshot {
21 return {
22 snapshotSequence: 1,
23 projects: [],
24 threads: [],
25 updatedAt: `2026-05-08T00:00:00.000Z`,
26 id,
27 } as OrchestrationShellSnapshot;
28}
29
30describe("createArchivedThreadsManager", () => {
31 afterEach(() => {
32 resetAtomRegistry();
33 });
34
35 it("loads archived snapshots for configured environment clients", async () => {
36 const envA = EnvironmentId.make("env-a");
37 const envB = EnvironmentId.make("env-b");
38 const clients = new Map<EnvironmentId, ArchivedThreadsClient>([
39 [
40 envA,
41 {
42 getArchivedShellSnapshot: vi.fn(async () => createSnapshot("a")),
43 },
44 ],
45 [
46 envB,
47 {
48 getArchivedShellSnapshot: vi.fn(async () => createSnapshot("b")),
49 },
50 ],
51 ]);
52 const manager = createArchivedThreadsManager({
53 getRegistry: () => registry,
54 getClient: (environmentId) => clients.get(environmentId) ?? null,
55 });
56
57 const result = registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envB, envA])));
58
59 await vi.waitFor(() => {
60 const state = readArchivedThreadsSnapshotState(
61 registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB]))),
62 );
63 expect(state.snapshots.map((snapshot) => snapshot.environmentId)).toEqual([envA, envB]);
64 });
65 expect(readArchivedThreadsSnapshotState(result).isLoading).toBe(true);
66 });
67
68 it("refreshes known snapshot groups that include an environment", async () => {
69 const envA = EnvironmentId.make("env-a");
70 const envB = EnvironmentId.make("env-b");
71 const getArchivedShellSnapshot = vi.fn(async () =>
72 createSnapshot(`a-${getArchivedShellSnapshot.mock.calls.length}`),
73 );
74 const manager = createArchivedThreadsManager({
75 getRegistry: () => registry,
76 getClient: (environmentId) => (environmentId === envA ? { getArchivedShellSnapshot } : null),
77 staleTimeMs: 60_000,
78 });
79
80 const atom = manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB]));
81 registry.get(atom);
82 await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(1));
83
84 manager.refreshForEnvironment(envA);
85
86 await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(2));
87 });
88
89 it("round-trips environment keys in sorted order", () => {
90 const envA = EnvironmentId.make("env-a");
91 const envB = EnvironmentId.make("env-b");
92 const key = makeArchivedThreadsEnvironmentKey([envB, envA]);
93
94 expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]);
95 });
96});
packages/client-runtime/src/archivedThreadsState.ts created+138
...@@ -0,0 +1,138 @@
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
2import * as Arr from "effect/Array";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import { pipe } from "effect/Function";
6import * as Order from "effect/Order";
7import * as Option from "effect/Option";
8import * as Result from "effect/Result";
9import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity";
10
11export type ArchivedSnapshotEntry = {
12 readonly environmentId: EnvironmentId;
13 readonly snapshot: OrchestrationShellSnapshot;
14};
15
16export interface ArchivedThreadsClient {
17 readonly getArchivedShellSnapshot: () => Promise<OrchestrationShellSnapshot>;
18}
19
20export interface ArchivedThreadsSnapshotState {
21 readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
22 readonly error: string | null;
23 readonly isLoading: boolean;
24}
25
26const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";
27const DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS = 5_000;
28const DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000;
29const environmentIdOrder = Order.String as Order.Order<EnvironmentId>;
30
31export function makeArchivedThreadsEnvironmentKey(
32 environmentIds: ReadonlyArray<EnvironmentId>,
33): string {
34 return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) =>
35 sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR),
36 );
37}
38
39export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<EnvironmentId> {
40 if (key.length === 0) {
41 return [];
42 }
43 return pipe(
44 key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR),
45 Arr.map((environmentId) => EnvironmentId.make(environmentId)),
46 );
47}
48
49export function readArchivedThreadsSnapshotState(
50 result: AsyncResult.AsyncResult<ReadonlyArray<ArchivedSnapshotEntry>, unknown>,
51): ArchivedThreadsSnapshotState {
52 const snapshots = Option.getOrElse(AsyncResult.value(result), () => []);
53 let error: string | null = null;
54 if (result._tag === "Failure") {
55 const cause = Cause.squash(result.cause);
56 error = cause instanceof Error ? cause.message : "Failed to load archived threads.";
57 }
58
59 return {
60 snapshots,
61 error,
62 isLoading: result.waiting,
63 };
64}
65
66export function createArchivedThreadsManager(config: {
67 readonly getRegistry: () => AtomRegistry.AtomRegistry;
68 readonly getClient: (environmentId: EnvironmentId) => ArchivedThreadsClient | null;
69 readonly staleTimeMs?: number;
70 readonly idleTtlMs?: number;
71}) {
72 const knownEnvironmentKeys = new Set<string>();
73 const knownEnvironmentIdsByKey = new Map<string, ReadonlySet<EnvironmentId>>();
74 const staleTime = config.staleTimeMs ?? DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS;
75 const idleTtl = config.idleTtlMs ?? DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS;
76
77 const snapshotsAtom = Atom.family((environmentKey: string) => {
78 knownEnvironmentKeys.add(environmentKey);
79 knownEnvironmentIdsByKey.set(
80 environmentKey,
81 new Set(parseArchivedThreadsEnvironmentKey(environmentKey)),
82 );
83 return Atom.make(
84 Effect.promise(async (): Promise<ReadonlyArray<ArchivedSnapshotEntry>> => {
85 const snapshots = await Promise.all(
86 pipe(
87 parseArchivedThreadsEnvironmentKey(environmentKey),
88 Arr.map(async (environmentId) => {
89 const client = config.getClient(environmentId);
90 if (!client) {
91 return null;
92 }
93 return {
94 environmentId,
95 snapshot: await client.getArchivedShellSnapshot(),
96 };
97 }),
98 ),
99 );
100 return pipe(
101 snapshots,
102 Arr.filterMap((snapshot) =>
103 snapshot !== null ? Result.succeed(snapshot) : Result.failVoid,
104 ),
105 );
106 }),
107 ).pipe(
108 Atom.swr({
109 staleTime,
110 revalidateOnMount: true,
111 }),
112 Atom.setIdleTTL(idleTtl),
113 Atom.withLabel(`archived-thread-snapshots:${environmentKey}`),
114 );
115 });
116
117 function getAtom(environmentKey: string) {
118 return snapshotsAtom(environmentKey);
119 }
120
121 function refresh(environmentIds: ReadonlyArray<EnvironmentId>): void {
122 config.getRegistry().refresh(getAtom(makeArchivedThreadsEnvironmentKey(environmentIds)));
123 }
124
125 function refreshForEnvironment(environmentId: EnvironmentId): void {
126 for (const environmentKey of knownEnvironmentKeys) {
127 if (knownEnvironmentIdsByKey.get(environmentKey)?.has(environmentId)) {
128 config.getRegistry().refresh(getAtom(environmentKey));
129 }
130 }
131 }
132
133 return {
134 getAtom,
135 refresh,
136 refreshForEnvironment,
137 };
138}
packages/client-runtime/src/checkpointDiffState.test.ts created+135
...@@ -0,0 +1,135 @@
1import { EnvironmentId, ThreadId, type OrchestrationGetTurnDiffResult } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vitest";
4
5import {
6 type CheckpointDiffClient,
7 createCheckpointDiffManager,
8 EMPTY_CHECKPOINT_DIFF_STATE,
9 getCheckpointDiffTargetKey,
10} from "./checkpointDiffState.ts";
11
12let registry = AtomRegistry.make();
13
14function resetAtomRegistry() {
15 registry.dispose();
16 registry = AtomRegistry.make();
17}
18
19const TARGET = {
20 environmentId: EnvironmentId.make("env-local"),
21 threadId: ThreadId.make("thread-1"),
22 fromTurnCount: 1,
23 toTurnCount: 2,
24 ignoreWhitespace: false,
25};
26
27const PATCH_RESULT: OrchestrationGetTurnDiffResult = {
28 threadId: TARGET.threadId,
29 diff: "patch",
30 fromTurnCount: 1,
31 toTurnCount: 2,
32};
33
34function createClient() {
35 return {
36 getTurnDiff: vi.fn(async () => PATCH_RESULT),
37 getFullThreadDiff: vi.fn(async () => PATCH_RESULT),
38 } satisfies CheckpointDiffClient;
39}
40
41describe("createCheckpointDiffManager", () => {
42 afterEach(() => {
43 resetAtomRegistry();
44 });
45
46 it("loads a turn checkpoint diff into atom state", async () => {
47 const client = createClient();
48 const manager = createCheckpointDiffManager({
49 getRegistry: () => registry,
50 getClient: () => client,
51 });
52
53 await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT);
54
55 expect(client.getTurnDiff).toHaveBeenCalledWith({
56 threadId: TARGET.threadId,
57 fromTurnCount: 1,
58 toTurnCount: 2,
59 ignoreWhitespace: false,
60 });
61 expect(client.getFullThreadDiff).not.toHaveBeenCalled();
62 expect(manager.getSnapshot(TARGET)).toEqual({
63 data: PATCH_RESULT,
64 error: null,
65 isPending: false,
66 });
67 });
68
69 it("loads a full thread diff when the range starts at zero", async () => {
70 const client = createClient();
71 const manager = createCheckpointDiffManager({
72 getRegistry: () => registry,
73 getClient: () => client,
74 });
75
76 await manager.load({ ...TARGET, fromTurnCount: 0 });
77
78 expect(client.getFullThreadDiff).toHaveBeenCalledWith({
79 threadId: TARGET.threadId,
80 toTurnCount: 2,
81 ignoreWhitespace: false,
82 });
83 expect(client.getTurnDiff).not.toHaveBeenCalled();
84 });
85
86 it("returns empty state for invalid targets", () => {
87 const manager = createCheckpointDiffManager({
88 getRegistry: () => registry,
89 getClient: () => createClient(),
90 });
91
92 expect(manager.getSnapshot({ ...TARGET, threadId: null })).toBe(EMPTY_CHECKPOINT_DIFF_STATE);
93 expect(getCheckpointDiffTargetKey({ ...TARGET, threadId: null })).toBeNull();
94 });
95
96 it("deduplicates in-flight requests and reuses successful cached data", async () => {
97 const client = createClient();
98 const manager = createCheckpointDiffManager({
99 getRegistry: () => registry,
100 getClient: () => client,
101 });
102
103 const first = manager.load(TARGET);
104 const second = manager.load(TARGET);
105
106 expect(first).toBe(second);
107 await first;
108 await manager.load(TARGET);
109
110 expect(client.getTurnDiff).toHaveBeenCalledTimes(1);
111 });
112
113 it("retries temporarily unavailable checkpoint diffs", async () => {
114 let attempts = 0;
115 const client = {
116 getFullThreadDiff: vi.fn(async () => PATCH_RESULT),
117 getTurnDiff: vi.fn(async () => {
118 attempts += 1;
119 if (attempts < 3) {
120 throw new Error("checkpoint is unavailable for turn");
121 }
122 return PATCH_RESULT;
123 }),
124 } satisfies CheckpointDiffClient;
125 const manager = createCheckpointDiffManager({
126 getRegistry: () => registry,
127 getClient: () => client,
128 retryDelay: async () => undefined,
129 });
130
131 await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT);
132
133 expect(client.getTurnDiff).toHaveBeenCalledTimes(3);
134 });
135});
packages/client-runtime/src/checkpointDiffState.ts created+313
...@@ -0,0 +1,313 @@
1import {
2 type EnvironmentId,
3 OrchestrationGetFullThreadDiffInput,
4 type OrchestrationGetFullThreadDiffResult,
5 OrchestrationGetTurnDiffInput,
6 type OrchestrationGetTurnDiffResult,
7 type ThreadId,
8} from "@t3tools/contracts";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
14
15export type CheckpointDiffResult =
16 | OrchestrationGetTurnDiffResult
17 | OrchestrationGetFullThreadDiffResult;
18
19export interface CheckpointDiffState {
20 readonly data: CheckpointDiffResult | null;
21 readonly error: string | null;
22 readonly isPending: boolean;
23}
24
25export interface CheckpointDiffTarget {
26 readonly environmentId: EnvironmentId | null;
27 readonly threadId: ThreadId | null;
28 readonly fromTurnCount: number | null;
29 readonly toTurnCount: number | null;
30 readonly ignoreWhitespace: boolean;
31 readonly cacheScope?: string | null;
32}
33
34export interface CheckpointDiffClient {
35 readonly getTurnDiff: (
36 input: OrchestrationGetTurnDiffInput,
37 ) => Promise<OrchestrationGetTurnDiffResult>;
38 readonly getFullThreadDiff: (
39 input: OrchestrationGetFullThreadDiffInput,
40 ) => Promise<OrchestrationGetFullThreadDiffResult>;
41}
42
43export const EMPTY_CHECKPOINT_DIFF_STATE = Object.freeze<CheckpointDiffState>({
44 data: null,
45 error: null,
46 isPending: false,
47});
48
49const INITIAL_CHECKPOINT_DIFF_STATE = Object.freeze<CheckpointDiffState>({
50 data: null,
51 error: null,
52 isPending: true,
53});
54
55const knownCheckpointDiffKeys = new Set<string>();
56
57export const checkpointDiffStateAtom = Atom.family((key: string) => {
58 knownCheckpointDiffKeys.add(key);
59 return Atom.make(INITIAL_CHECKPOINT_DIFF_STATE).pipe(
60 Atom.keepAlive,
61 Atom.withLabel(`checkpoint-diff:${key}`),
62 );
63});
64
65export const EMPTY_CHECKPOINT_DIFF_ATOM = Atom.make(EMPTY_CHECKPOINT_DIFF_STATE).pipe(
66 Atom.keepAlive,
67 Atom.withLabel("checkpoint-diff:null"),
68);
69
70const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput);
71const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput);
72
73type CheckpointDiffRequest =
74 | {
75 readonly kind: "fullThreadDiff";
76 readonly input: OrchestrationGetFullThreadDiffInput;
77 }
78 | {
79 readonly kind: "turnDiff";
80 readonly input: OrchestrationGetTurnDiffInput;
81 };
82
83export function getCheckpointDiffTargetKey(target: CheckpointDiffTarget): string | null {
84 const decoded = decodeCheckpointDiffRequest(target);
85 if (target.environmentId === null || decoded._tag === "None") {
86 return null;
87 }
88
89 return [
90 target.environmentId,
91 target.threadId,
92 target.fromTurnCount,
93 target.toTurnCount,
94 target.ignoreWhitespace,
95 target.cacheScope ?? null,
96 ].join(":");
97}
98
99function decodeCheckpointDiffRequest(target: CheckpointDiffTarget) {
100 if (target.fromTurnCount === 0) {
101 return decodeFullThreadDiffInput({
102 threadId: target.threadId,
103 toTurnCount: target.toTurnCount,
104 ignoreWhitespace: target.ignoreWhitespace,
105 }).pipe(Option.map((input) => ({ kind: "fullThreadDiff" as const, input })));
106 }
107
108 return decodeTurnDiffInput({
109 threadId: target.threadId,
110 fromTurnCount: target.fromTurnCount,
111 toTurnCount: target.toTurnCount,
112 ignoreWhitespace: target.ignoreWhitespace,
113 }).pipe(Option.map((input) => ({ kind: "turnDiff" as const, input })));
114}
115
116function asCheckpointErrorMessage(error: unknown): string {
117 if (error instanceof Error) {
118 return error.message;
119 }
120 if (typeof error === "string") {
121 return error;
122 }
123 return "";
124}
125
126export function normalizeCheckpointDiffErrorMessage(error: unknown): string {
127 const message = asCheckpointErrorMessage(error).trim();
128 if (message.length === 0) {
129 return "Failed to load checkpoint diff.";
130 }
131
132 const lower = message.toLowerCase();
133 if (lower.includes("not a git repository")) {
134 return "Turn diffs are unavailable because this project is not a git repository.";
135 }
136
137 if (
138 lower.includes("checkpoint unavailable for thread") ||
139 lower.includes("checkpoint invariant violation")
140 ) {
141 const separatorIndex = message.indexOf(":");
142 if (separatorIndex >= 0) {
143 const detail = message.slice(separatorIndex + 1).trim();
144 if (detail.length > 0) {
145 return detail;
146 }
147 }
148 }
149
150 return message;
151}
152
153function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
154 const message = asCheckpointErrorMessage(error).toLowerCase();
155 return (
156 message.includes("exceeds current turn count") ||
157 message.includes("checkpoint is unavailable for turn") ||
158 message.includes("filesystem checkpoint is unavailable")
159 );
160}
161
162function defaultRetryDelay(attempt: number, error: unknown): Promise<void> {
163 const delayMs = isCheckpointTemporarilyUnavailable(error)
164 ? Math.min(5_000, 250 * 2 ** (attempt - 1))
165 : Math.min(1_000, 100 * 2 ** (attempt - 1));
166 return Effect.runPromise(Effect.sleep(Duration.millis(delayMs)));
167}
168
169export function createCheckpointDiffManager(config: {
170 readonly getRegistry: () => AtomRegistry.AtomRegistry;
171 readonly getClient: (environmentId: EnvironmentId) => CheckpointDiffClient | null;
172 readonly retryDelay?: (attempt: number, error: unknown) => Promise<void>;
173}) {
174 const inFlight = new Map<string, Promise<CheckpointDiffResult | null>>();
175 const versions = new Map<string, number>();
176
177 function getVersion(targetKey: string): number {
178 return versions.get(targetKey) ?? 0;
179 }
180
181 function bumpVersion(targetKey: string): void {
182 versions.set(targetKey, getVersion(targetKey) + 1);
183 }
184
185 function setState(targetKey: string, state: CheckpointDiffState): void {
186 config.getRegistry().set(checkpointDiffStateAtom(targetKey), state);
187 }
188
189 function markPending(targetKey: string): void {
190 const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey));
191 setState(
192 targetKey,
193 current.data === null ? INITIAL_CHECKPOINT_DIFF_STATE : { ...current, isPending: true },
194 );
195 }
196
197 function setError(targetKey: string, error: unknown): void {
198 const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey));
199 setState(targetKey, {
200 data: current.data,
201 error: normalizeCheckpointDiffErrorMessage(error),
202 isPending: false,
203 });
204 }
205
206 async function requestWithRetry(
207 client: CheckpointDiffClient,
208 request: CheckpointDiffRequest,
209 ): Promise<CheckpointDiffResult> {
210 let attempt = 0;
211 while (true) {
212 attempt += 1;
213 try {
214 if (request.kind === "fullThreadDiff") {
215 return await client.getFullThreadDiff(request.input);
216 }
217 return await client.getTurnDiff(request.input);
218 } catch (error) {
219 const maxAttempts = isCheckpointTemporarilyUnavailable(error) ? 13 : 4;
220 if (attempt >= maxAttempts) {
221 throw error;
222 }
223 await (config.retryDelay ?? defaultRetryDelay)(attempt, error);
224 }
225 }
226 }
227
228 function load(
229 target: CheckpointDiffTarget,
230 client?: CheckpointDiffClient,
231 options?: { readonly force?: boolean },
232 ): Promise<CheckpointDiffResult | null> {
233 const targetKey = getCheckpointDiffTargetKey(target);
234 const decoded = decodeCheckpointDiffRequest(target);
235 if (targetKey === null || target.environmentId === null || decoded._tag === "None") {
236 return Promise.resolve(null);
237 }
238
239 if (!options?.force) {
240 const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey));
241 if (current.data !== null && current.error === null) {
242 return Promise.resolve(current.data);
243 }
244 }
245
246 const existing = inFlight.get(targetKey);
247 if (existing) {
248 return existing;
249 }
250
251 const resolved = client ?? config.getClient(target.environmentId);
252 if (!resolved) {
253 setError(targetKey, new Error("Remote connection is not ready."));
254 return Promise.resolve(config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data);
255 }
256
257 markPending(targetKey);
258 const version = getVersion(targetKey);
259 const promise = requestWithRetry(resolved, decoded.value).then(
260 (result) => {
261 if (getVersion(targetKey) === version) {
262 setState(targetKey, { data: result, error: null, isPending: false });
263 }
264 return result;
265 },
266 (error: unknown) => {
267 if (getVersion(targetKey) === version) {
268 setError(targetKey, error);
269 }
270 return config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data;
271 },
272 );
273 inFlight.set(targetKey, promise);
274 void promise.finally(() => {
275 if (inFlight.get(targetKey) === promise) {
276 inFlight.delete(targetKey);
277 }
278 });
279 return promise;
280 }
281
282 function getSnapshot(target: CheckpointDiffTarget): CheckpointDiffState {
283 const targetKey = getCheckpointDiffTargetKey(target);
284 return targetKey === null
285 ? EMPTY_CHECKPOINT_DIFF_STATE
286 : config.getRegistry().get(checkpointDiffStateAtom(targetKey));
287 }
288
289 function invalidate(target?: CheckpointDiffTarget): void {
290 if (target) {
291 const targetKey = getCheckpointDiffTargetKey(target);
292 if (targetKey === null) {
293 return;
294 }
295 bumpVersion(targetKey);
296 inFlight.delete(targetKey);
297 setState(targetKey, INITIAL_CHECKPOINT_DIFF_STATE);
298 return;
299 }
300
301 for (const key of knownCheckpointDiffKeys) {
302 bumpVersion(key);
303 setState(key, INITIAL_CHECKPOINT_DIFF_STATE);
304 }
305 inFlight.clear();
306 }
307
308 return {
309 getSnapshot,
310 invalidate,
311 load,
312 };
313}
packages/client-runtime/src/composerPathSearchState.test.ts created+185
...@@ -0,0 +1,185 @@
1import { assert, beforeEach, it, vi } from "vitest";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 type ComposerPathSearchClient,
7 createComposerPathSearchManager,
8 EMPTY_COMPOSER_PATH_SEARCH_STATE,
9 getComposerPathSearchTargetKey,
10} from "./composerPathSearchState.ts";
11
12let registry = AtomRegistry.make();
13
14const noop = () => undefined;
15
16beforeEach(() => {
17 registry.dispose();
18 registry = AtomRegistry.make();
19});
20
21function flushAsyncWork(): Promise<void> {
22 return Promise.resolve().then(() => undefined);
23}
24
25const TARGET = {
26 environmentId: "env-local" as EnvironmentId,
27 cwd: "/repo",
28 query: "src",
29};
30
31it("derives null keys for inactive path searches", () => {
32 assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, query: "" }), null);
33 assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, cwd: null }), null);
34 assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, environmentId: null }), null);
35});
36
37it("stores path search results in atom state", async () => {
38 const manager = createComposerPathSearchManager({
39 getRegistry: () => registry,
40 debounceMs: 0,
41 getClient: () => ({
42 searchEntries: async () => ({
43 entries: [
44 { path: "src/index.ts", kind: "file" },
45 { path: "src/components", kind: "directory" },
46 ],
47 truncated: false,
48 }),
49 }),
50 });
51
52 manager.search(TARGET);
53 await flushAsyncWork();
54
55 assert.deepStrictEqual(manager.getSnapshot(TARGET), {
56 entries: [
57 { path: "src/index.ts", kind: "file" },
58 { path: "src/components", kind: "directory" },
59 ],
60 isPending: false,
61 error: null,
62 });
63});
64
65it("reuses fresh cached path search results", async () => {
66 const searchEntries = vi.fn(async () => ({
67 entries: [{ path: "src/index.ts", kind: "file" as const }],
68 truncated: false,
69 }));
70 const manager = createComposerPathSearchManager({
71 getRegistry: () => registry,
72 debounceMs: 0,
73 staleTimeMs: 15_000,
74 getClient: () => ({ searchEntries }),
75 });
76
77 manager.search(TARGET);
78 await flushAsyncWork();
79 manager.search(TARGET);
80 await flushAsyncWork();
81
82 assert.strictEqual(searchEntries.mock.calls.length, 1);
83 assert.deepStrictEqual(manager.getSnapshot(TARGET), {
84 entries: [{ path: "src/index.ts", kind: "file" }],
85 isPending: false,
86 error: null,
87 });
88});
89
90it("invalidates watched path searches and refreshes without clearing entries", async () => {
91 type SearchResult = Awaited<ReturnType<ComposerPathSearchClient["searchEntries"]>>;
92
93 let resolveSecond: (value: SearchResult) => void = noop;
94 let callCount = 0;
95 const searchEntries = vi.fn((() => {
96 callCount += 1;
97 if (callCount === 1) {
98 return Promise.resolve({
99 entries: [{ path: "src/old.ts", kind: "file" as const }],
100 truncated: false,
101 });
102 }
103 return new Promise((resolve) => {
104 resolveSecond = resolve;
105 });
106 }) satisfies ComposerPathSearchClient["searchEntries"]);
107 const manager = createComposerPathSearchManager({
108 getRegistry: () => registry,
109 debounceMs: 0,
110 staleTimeMs: 15_000,
111 getClient: () => ({ searchEntries }),
112 });
113
114 const unwatch = manager.watch(TARGET);
115 await flushAsyncWork();
116 manager.invalidate();
117
118 assert.deepStrictEqual(manager.getSnapshot(TARGET), {
119 entries: [{ path: "src/old.ts", kind: "file" }],
120 isPending: true,
121 error: null,
122 });
123
124 resolveSecond({
125 entries: [{ path: "src/new.ts", kind: "file" }],
126 truncated: false,
127 });
128 await flushAsyncWork();
129
130 assert.deepStrictEqual(manager.getSnapshot(TARGET), {
131 entries: [{ path: "src/new.ts", kind: "file" }],
132 isPending: false,
133 error: null,
134 });
135 assert.strictEqual(searchEntries.mock.calls.length, 2);
136 unwatch();
137});
138
139it("ignores stale path search results after a newer request starts", async () => {
140 let resolveFirst: (value: {
141 entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>;
142 truncated: boolean;
143 }) => void = noop;
144 const manager = createComposerPathSearchManager({
145 getRegistry: () => registry,
146 debounceMs: 0,
147 getClient: () => ({
148 searchEntries: (input: Parameters<ComposerPathSearchClient["searchEntries"]>[0]) => {
149 if (input.query === "first") {
150 return new Promise((resolve) => {
151 resolveFirst = resolve;
152 });
153 }
154 return Promise.resolve({
155 entries: [{ path: "second.ts", kind: "file" }],
156 truncated: false,
157 });
158 },
159 }),
160 });
161
162 manager.search({ ...TARGET, query: "first" });
163 manager.search({ ...TARGET, query: "second" });
164 await flushAsyncWork();
165 resolveFirst({ entries: [{ path: "first.ts", kind: "file" }], truncated: false });
166 await flushAsyncWork();
167
168 assert.deepStrictEqual(manager.getSnapshot({ ...TARGET, query: "second" }), {
169 entries: [{ path: "second.ts", kind: "file" }],
170 isPending: false,
171 error: null,
172 });
173});
174
175it("returns the empty snapshot for inactive targets", () => {
176 const manager = createComposerPathSearchManager({
177 getRegistry: () => registry,
178 getClient: () => null,
179 });
180
181 assert.deepStrictEqual(
182 manager.getSnapshot({ environmentId: null, cwd: null, query: null }),
183 EMPTY_COMPOSER_PATH_SEARCH_STATE,
184 );
185});
packages/client-runtime/src/composerPathSearchState.ts created+346
...@@ -0,0 +1,346 @@
1import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
7
8export interface ComposerPathSearchEntry {
9 readonly path: string;
10 readonly kind: "file" | "directory";
11 readonly parentPath?: string;
12}
13
14export interface ComposerPathSearchState {
15 readonly entries: ReadonlyArray<ComposerPathSearchEntry>;
16 readonly isPending: boolean;
17 readonly error: string | null;
18}
19
20export interface ComposerPathSearchTarget {
21 readonly environmentId: EnvironmentId | null;
22 readonly cwd: string | null;
23 readonly query: string | null;
24}
25
26export interface ComposerPathSearchClient {
27 readonly searchEntries: (input: {
28 readonly cwd: string;
29 readonly query: string;
30 readonly limit: number;
31 }) => Promise<ProjectSearchEntriesResult>;
32}
33
34interface WatchedEntry {
35 refCount: number;
36 target: ComposerPathSearchTarget & {
37 readonly environmentId: EnvironmentId;
38 readonly cwd: string;
39 };
40 teardown: () => void;
41}
42
43export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({
44 entries: [],
45 isPending: false,
46 error: null,
47});
48
49const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({
50 entries: [],
51 isPending: true,
52 error: null,
53});
54
55const NOOP: () => void = () => undefined;
56const DEFAULT_DEBOUNCE_MS = 200;
57const DEFAULT_LIMIT = 20;
58
59export const composerPathSearchStateAtom = Atom.family((key: string) =>
60 Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe(
61 Atom.keepAlive,
62 Atom.withLabel(`composer-path-search:${key}`),
63 ),
64);
65
66export const EMPTY_COMPOSER_PATH_SEARCH_ATOM = Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe(
67 Atom.keepAlive,
68 Atom.withLabel("composer-path-search:null"),
69);
70
71export function normalizeComposerPathSearchQuery(query: string | null): string {
72 return query?.trim() ?? "";
73}
74
75export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget): string | null {
76 const query = normalizeComposerPathSearchQuery(target.query);
77 if (target.environmentId === null || target.cwd === null || query.length === 0) {
78 return null;
79 }
80
81 return `${target.environmentId}:${target.cwd}:${query}`;
82}
83
84function toSearchEntries(
85 entries: ProjectSearchEntriesResult["entries"],
86): ReadonlyArray<ComposerPathSearchEntry> {
87 return entries.map((entry) => ({
88 path: entry.path,
89 kind: entry.kind === "directory" ? "directory" : "file",
90 ...(entry.parentPath !== undefined ? { parentPath: entry.parentPath } : {}),
91 }));
92}
93
94export function createComposerPathSearchManager(config: {
95 readonly getRegistry: () => AtomRegistry.AtomRegistry;
96 readonly getClient: (environmentId: EnvironmentId) => ComposerPathSearchClient | null;
97 readonly subscribeClientChanges?: (listener: () => void) => () => void;
98 readonly debounceMs?: number;
99 readonly limit?: number;
100 readonly staleTimeMs?: number;
101}) {
102 const watched = new Map<string, WatchedEntry>();
103 const versions = new Map<string, number>();
104 const timers = new Map<string, Fiber.Fiber<void, never>>();
105 const lastLoadedAt = new Map<string, number>();
106 const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
107 const limit = config.limit ?? DEFAULT_LIMIT;
108
109 function bumpVersion(targetKey: string): number {
110 const next = (versions.get(targetKey) ?? 0) + 1;
111 versions.set(targetKey, next);
112 return next;
113 }
114
115 function setState(targetKey: string, state: ComposerPathSearchState): void {
116 config.getRegistry().set(composerPathSearchStateAtom(targetKey), state);
117 }
118
119 function clearTimer(targetKey: string): void {
120 const fiber = timers.get(targetKey);
121 if (fiber) {
122 Effect.runFork(Fiber.interrupt(fiber));
123 timers.delete(targetKey);
124 }
125 }
126
127 function getSnapshot(target: ComposerPathSearchTarget): ComposerPathSearchState {
128 const targetKey = getComposerPathSearchTargetKey(target);
129 return targetKey === null
130 ? EMPTY_COMPOSER_PATH_SEARCH_STATE
131 : config.getRegistry().get(composerPathSearchStateAtom(targetKey));
132 }
133
134 function runSearch(
135 targetKey: string,
136 target: ComposerPathSearchTarget & {
137 readonly environmentId: EnvironmentId;
138 readonly cwd: string;
139 },
140 client: ComposerPathSearchClient,
141 version: number,
142 ): void {
143 void client
144 .searchEntries({
145 cwd: target.cwd,
146 query: normalizeComposerPathSearchQuery(target.query),
147 limit,
148 })
149 .then((result) => {
150 if (versions.get(targetKey) !== version) {
151 return;
152 }
153 setState(targetKey, {
154 entries: toSearchEntries(result.entries),
155 isPending: false,
156 error: null,
157 });
158 lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis));
159 })
160 .catch((error: unknown) => {
161 if (versions.get(targetKey) !== version) {
162 return;
163 }
164 const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey));
165 setState(targetKey, {
166 entries: current.entries,
167 isPending: false,
168 error: error instanceof Error ? error.message : "Failed to search project files.",
169 });
170 });
171 }
172
173 function search(
174 target: ComposerPathSearchTarget,
175 client?: ComposerPathSearchClient,
176 options?: { readonly force?: boolean },
177 ): void {
178 const targetKey = getComposerPathSearchTargetKey(target);
179 if (targetKey === null || target.environmentId === null || target.cwd === null) {
180 return;
181 }
182
183 const resolved = client ?? config.getClient(target.environmentId);
184 if (!resolved) {
185 setState(targetKey, {
186 entries: [],
187 isPending: false,
188 error: "Remote connection is not ready.",
189 });
190 return;
191 }
192
193 const lastLoaded = lastLoadedAt.get(targetKey);
194 if (
195 !options?.force &&
196 lastLoaded !== undefined &&
197 config.staleTimeMs !== undefined &&
198 Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs
199 ) {
200 return;
201 }
202
203 const version = bumpVersion(targetKey);
204 clearTimer(targetKey);
205 const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey));
206 setState(
207 targetKey,
208 current.entries.length === 0
209 ? PENDING_COMPOSER_PATH_SEARCH_STATE
210 : { ...current, isPending: true, error: null },
211 );
212
213 const readyTarget = {
214 ...target,
215 environmentId: target.environmentId,
216 cwd: target.cwd,
217 };
218
219 if (debounceMs <= 0) {
220 runSearch(targetKey, readyTarget, resolved, version);
221 return;
222 }
223
224 const fiber = Effect.runFork(
225 Effect.sleep(Duration.millis(debounceMs)).pipe(
226 Effect.andThen(
227 Effect.sync(() => {
228 timers.delete(targetKey);
229 runSearch(targetKey, readyTarget, resolved, version);
230 }),
231 ),
232 ),
233 );
234 timers.set(targetKey, fiber);
235 }
236
237 function watch(target: ComposerPathSearchTarget): () => void {
238 const targetKey = getComposerPathSearchTargetKey(target);
239 if (targetKey === null || target.environmentId === null || target.cwd === null) {
240 return NOOP;
241 }
242
243 const readyTarget = {
244 ...target,
245 environmentId: target.environmentId,
246 cwd: target.cwd,
247 };
248
249 const existing = watched.get(targetKey);
250 if (existing) {
251 existing.refCount += 1;
252 return () => unwatch(targetKey);
253 }
254
255 let currentClient: ComposerPathSearchClient | null = null;
256 const sync = () => {
257 const client = config.getClient(target.environmentId!);
258 if (!client) {
259 currentClient = null;
260 setState(targetKey, {
261 entries: [],
262 isPending: false,
263 error: "Remote connection is not ready.",
264 });
265 return;
266 }
267
268 if (currentClient === client) {
269 return;
270 }
271
272 currentClient = client;
273 search(readyTarget, client);
274 };
275
276 const unsubscribe = config.subscribeClientChanges?.(sync) ?? NOOP;
277 sync();
278
279 watched.set(targetKey, {
280 refCount: 1,
281 target: readyTarget,
282 teardown: () => {
283 unsubscribe();
284 clearTimer(targetKey);
285 bumpVersion(targetKey);
286 },
287 });
288
289 return () => unwatch(targetKey);
290 }
291
292 function unwatch(targetKey: string): void {
293 const entry = watched.get(targetKey);
294 if (!entry) {
295 return;
296 }
297
298 entry.refCount -= 1;
299 if (entry.refCount > 0) {
300 return;
301 }
302
303 entry.teardown();
304 watched.delete(targetKey);
305 }
306
307 function reset(): void {
308 for (const entry of watched.values()) {
309 entry.teardown();
310 }
311 watched.clear();
312 versions.clear();
313 for (const targetKey of timers.keys()) {
314 clearTimer(targetKey);
315 }
316 lastLoadedAt.clear();
317 }
318
319 function invalidate(target?: ComposerPathSearchTarget): void {
320 if (target) {
321 const targetKey = getComposerPathSearchTargetKey(target);
322 if (targetKey === null) {
323 return;
324 }
325 lastLoadedAt.delete(targetKey);
326 const watchedEntry = watched.get(targetKey);
327 if (watchedEntry) {
328 search(watchedEntry.target, undefined, { force: true });
329 }
330 return;
331 }
332
333 lastLoadedAt.clear();
334 for (const watchedEntry of watched.values()) {
335 search(watchedEntry.target, undefined, { force: true });
336 }
337 }
338
339 return {
340 invalidate,
341 getSnapshot,
342 search,
343 watch,
344 reset,
345 };
346}
packages/client-runtime/src/environmentConnection.ts created+244
...@@ -0,0 +1,244 @@
1import type {
2 EnvironmentId,
3 OrchestrationShellSnapshot,
4 OrchestrationShellStreamEvent,
5 ServerConfig,
6 ServerLifecycleWelcomePayload,
7 TerminalEvent,
8} from "@t3tools/contracts";
9
10import type { KnownEnvironment } from "./knownEnvironment.ts";
11import type { WsRpcClient } from "./wsRpcClient.ts";
12
13export interface EnvironmentConnection {
14 readonly kind: "primary" | "saved";
15 readonly environmentId: EnvironmentId;
16 readonly knownEnvironment: KnownEnvironment;
17 readonly client: WsRpcClient;
18 readonly ensureBootstrapped: () => Promise<void>;
19 readonly reconnect: () => Promise<void>;
20 readonly dispose: () => Promise<void>;
21}
22
23interface OrchestrationHandlers {
24 readonly applyShellEvent: (
25 event: OrchestrationShellStreamEvent,
26 environmentId: EnvironmentId,
27 ) => void;
28 readonly syncShellSnapshot: (
29 snapshot: OrchestrationShellSnapshot,
30 environmentId: EnvironmentId,
31 ) => void;
32 readonly applyTerminalEvent?: (event: TerminalEvent, environmentId: EnvironmentId) => void;
33}
34
35export interface EnvironmentConnectionInput extends OrchestrationHandlers {
36 readonly kind: "primary" | "saved";
37 readonly knownEnvironment: KnownEnvironment;
38 readonly client: WsRpcClient;
39 readonly refreshMetadata?: () => Promise<void>;
40 readonly onConfigSnapshot?: (config: ServerConfig) => void;
41 readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void;
42 readonly onShellResubscribe?: (environmentId: EnvironmentId) => void;
43}
44
45export interface EnvironmentConnectionAttempt {
46 readonly environmentId: EnvironmentId;
47 readonly isCurrent: () => boolean;
48}
49
50export class EnvironmentConnectionAttemptCancelledError extends Error {
51 constructor(environmentId: EnvironmentId) {
52 super(`Environment connection attempt ${environmentId} was cancelled.`);
53 this.name = "EnvironmentConnectionAttemptCancelledError";
54 }
55}
56
57export function createEnvironmentConnectionAttemptRegistry() {
58 const attempts = new Map<EnvironmentId, symbol>();
59
60 return {
61 begin: (environmentId: EnvironmentId): EnvironmentConnectionAttempt => {
62 const id = Symbol(environmentId);
63 attempts.set(environmentId, id);
64 return {
65 environmentId,
66 isCurrent: () => attempts.get(environmentId) === id,
67 };
68 },
69 cancel: (environmentId: EnvironmentId): void => {
70 attempts.delete(environmentId);
71 },
72 clear: (): void => {
73 attempts.clear();
74 },
75 };
76}
77
78export class EnvironmentConnectionDisposedError extends Error {
79 constructor(environmentId: EnvironmentId) {
80 super(`Environment connection ${environmentId} was disposed before it finished bootstrapping.`);
81 this.name = "EnvironmentConnectionDisposedError";
82 }
83}
84
85function createBootstrapGate() {
86 let resolve: (() => void) | null = null;
87 let reject: ((error: unknown) => void) | null = null;
88 const makePromise = () => {
89 const nextPromise = new Promise<void>((nextResolve, nextReject) => {
90 resolve = nextResolve;
91 reject = nextReject;
92 });
93 void nextPromise.catch(() => undefined);
94 return nextPromise;
95 };
96 let promise = makePromise();
97
98 return {
99 wait: () => promise,
100 resolve: () => {
101 resolve?.();
102 resolve = null;
103 reject = null;
104 },
105 reject: (error: unknown) => {
106 reject?.(error);
107 resolve = null;
108 reject = null;
109 },
110 reset: () => {
111 promise = makePromise();
112 },
113 };
114}
115
116export function createEnvironmentConnection(
117 input: EnvironmentConnectionInput,
118): EnvironmentConnection {
119 const environmentId = input.knownEnvironment.environmentId;
120
121 if (!environmentId) {
122 throw new Error(
123 `Known environment ${input.knownEnvironment.label} is missing its environmentId.`,
124 );
125 }
126
127 let disposed = false;
128 const bootstrapGate = createBootstrapGate();
129 const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined;
130 const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined;
131
132 const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => {
133 if (environmentId !== nextEnvironmentId) {
134 throw new Error(
135 `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`,
136 );
137 }
138 };
139
140 const unsubLifecycle = shouldObserveLifecycle
141 ? input.client.server.subscribeLifecycle((event) => {
142 if (disposed || event.type !== "welcome") {
143 return;
144 }
145
146 observeEnvironmentIdentity(
147 event.payload.environment.environmentId,
148 "server lifecycle welcome",
149 );
150 input.onWelcome?.(event.payload);
151 })
152 : () => undefined;
153
154 const unsubConfig = shouldObserveConfig
155 ? input.client.server.subscribeConfig((event) => {
156 if (disposed || event.type !== "snapshot") {
157 return;
158 }
159
160 observeEnvironmentIdentity(
161 event.config.environment.environmentId,
162 "server config snapshot",
163 );
164 input.onConfigSnapshot?.(event.config);
165 })
166 : () => undefined;
167
168 const unsubShell = input.client.orchestration.subscribeShell(
169 (item) => {
170 if (disposed) {
171 return;
172 }
173
174 if (item.kind === "snapshot") {
175 input.syncShellSnapshot(item.snapshot, environmentId);
176 bootstrapGate.resolve();
177 return;
178 }
179
180 input.applyShellEvent(item, environmentId);
181 },
182 {
183 onResubscribe: () => {
184 if (disposed) {
185 return;
186 }
187
188 bootstrapGate.reset();
189 input.onShellResubscribe?.(environmentId);
190 },
191 },
192 );
193
194 const unsubTerminalEvent = input.applyTerminalEvent
195 ? input.client.terminal.onEvent((event) => {
196 if (!disposed) {
197 input.applyTerminalEvent?.(event, environmentId);
198 }
199 })
200 : () => undefined;
201
202 const cleanup = () => {
203 if (disposed) {
204 return;
205 }
206
207 disposed = true;
208 bootstrapGate.reject(new EnvironmentConnectionDisposedError(environmentId));
209 unsubShell();
210 unsubTerminalEvent();
211 unsubLifecycle();
212 unsubConfig();
213 };
214
215 return {
216 kind: input.kind,
217 environmentId,
218 knownEnvironment: input.knownEnvironment,
219 client: input.client,
220 ensureBootstrapped: () =>
221 disposed
222 ? Promise.reject(new EnvironmentConnectionDisposedError(environmentId))
223 : bootstrapGate.wait(),
224 reconnect: async () => {
225 if (disposed) {
226 throw new EnvironmentConnectionDisposedError(environmentId);
227 }
228
229 bootstrapGate.reset();
230 try {
231 await input.client.reconnect();
232 await input.refreshMetadata?.();
233 await bootstrapGate.wait();
234 } catch (error) {
235 bootstrapGate.reject(error);
236 throw error;
237 }
238 },
239 dispose: async () => {
240 cleanup();
241 await input.client.dispose();
242 },
243 };
244}
packages/client-runtime/src/environmentRuntimeState.test.ts created+75
...@@ -0,0 +1,75 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import { EnvironmentId } from "@t3tools/contracts";
3import { afterEach, describe, expect, it } from "vitest";
4
5import { createEnvironmentRuntimeManager } from "./environmentRuntimeState.ts";
6
7let atomRegistry = AtomRegistry.make();
8
9function resetAtomRegistry() {
10 atomRegistry.dispose();
11 atomRegistry = AtomRegistry.make();
12}
13
14const TARGET = { environmentId: EnvironmentId.make("env-local") } as const;
15
16describe("createEnvironmentRuntimeManager", () => {
17 afterEach(() => {
18 resetAtomRegistry();
19 });
20
21 it("stores state per environment", () => {
22 const manager = createEnvironmentRuntimeManager({
23 getRegistry: () => atomRegistry,
24 });
25
26 manager.setState(TARGET, {
27 connectionState: "ready",
28 connectionError: null,
29 serverConfig: null,
30 });
31
32 expect(manager.getSnapshot(TARGET)).toEqual({
33 connectionState: "ready",
34 connectionError: null,
35 serverConfig: null,
36 });
37 });
38
39 it("patches the current state", () => {
40 const manager = createEnvironmentRuntimeManager({
41 getRegistry: () => atomRegistry,
42 });
43
44 manager.patch(TARGET, (current) => ({
45 ...current,
46 connectionState: "disconnected",
47 connectionError: "Socket closed.",
48 }));
49
50 expect(manager.getSnapshot(TARGET)).toEqual({
51 connectionState: "disconnected",
52 connectionError: "Socket closed.",
53 serverConfig: null,
54 });
55 });
56
57 it("invalidates a single environment", () => {
58 const manager = createEnvironmentRuntimeManager({
59 getRegistry: () => atomRegistry,
60 });
61
62 manager.setState(TARGET, {
63 connectionState: "ready",
64 connectionError: null,
65 serverConfig: null,
66 });
67 manager.invalidate(TARGET);
68
69 expect(manager.getSnapshot(TARGET)).toEqual({
70 connectionState: "idle",
71 connectionError: null,
72 serverConfig: null,
73 });
74 });
75});
packages/client-runtime/src/environmentRuntimeState.ts created+104
...@@ -0,0 +1,104 @@
1import type { EnvironmentId, ServerConfig as T3ServerConfig } from "@t3tools/contracts";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3
4export type EnvironmentConnectionState =
5 | "idle"
6 | "connecting"
7 | "ready"
8 | "reconnecting"
9 | "disconnected";
10
11export interface EnvironmentRuntimeState {
12 readonly connectionState: EnvironmentConnectionState;
13 readonly connectionError: string | null;
14 readonly serverConfig: T3ServerConfig | null;
15}
16
17export interface EnvironmentRuntimeTarget {
18 readonly environmentId: EnvironmentId | null;
19}
20
21export const EMPTY_ENVIRONMENT_RUNTIME_STATE = Object.freeze<EnvironmentRuntimeState>({
22 connectionState: "idle",
23 connectionError: null,
24 serverConfig: null,
25});
26
27const knownEnvironmentRuntimeKeys = new Set<string>();
28
29export const environmentRuntimeStateAtom = Atom.family((key: string) => {
30 knownEnvironmentRuntimeKeys.add(key);
31 return Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe(
32 Atom.keepAlive,
33 Atom.withLabel(`environment-runtime:${key}`),
34 );
35});
36
37export const EMPTY_ENVIRONMENT_RUNTIME_ATOM = Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe(
38 Atom.keepAlive,
39 Atom.withLabel("environment-runtime:null"),
40);
41
42export function getEnvironmentRuntimeTargetKey(target: EnvironmentRuntimeTarget): string | null {
43 return target.environmentId;
44}
45
46export interface EnvironmentRuntimeManagerConfig {
47 readonly getRegistry: () => AtomRegistry.AtomRegistry;
48}
49
50export function createEnvironmentRuntimeManager(config: EnvironmentRuntimeManagerConfig) {
51 function getSnapshot(target: EnvironmentRuntimeTarget): EnvironmentRuntimeState {
52 const targetKey = getEnvironmentRuntimeTargetKey(target);
53 if (targetKey === null) {
54 return EMPTY_ENVIRONMENT_RUNTIME_STATE;
55 }
56
57 return config.getRegistry().get(environmentRuntimeStateAtom(targetKey));
58 }
59
60 function setState(target: EnvironmentRuntimeTarget, nextState: EnvironmentRuntimeState): void {
61 const targetKey = getEnvironmentRuntimeTargetKey(target);
62 if (targetKey === null) {
63 return;
64 }
65
66 config.getRegistry().set(environmentRuntimeStateAtom(targetKey), nextState);
67 }
68
69 function patch(
70 target: EnvironmentRuntimeTarget,
71 updater: (current: EnvironmentRuntimeState) => EnvironmentRuntimeState,
72 ): void {
73 const targetKey = getEnvironmentRuntimeTargetKey(target);
74 if (targetKey === null) {
75 return;
76 }
77
78 const current = config.getRegistry().get(environmentRuntimeStateAtom(targetKey));
79 config.getRegistry().set(environmentRuntimeStateAtom(targetKey), updater(current));
80 }
81
82 function invalidate(target?: EnvironmentRuntimeTarget): void {
83 if (target) {
84 setState(target, EMPTY_ENVIRONMENT_RUNTIME_STATE);
85 return;
86 }
87
88 for (const key of knownEnvironmentRuntimeKeys) {
89 config.getRegistry().set(environmentRuntimeStateAtom(key), EMPTY_ENVIRONMENT_RUNTIME_STATE);
90 }
91 }
92
93 function reset(): void {
94 invalidate();
95 }
96
97 return {
98 getSnapshot,
99 setState,
100 patch,
101 invalidate,
102 reset,
103 };
104}
packages/client-runtime/src/filesystemBrowseState.test.ts created+119
...@@ -0,0 +1,119 @@
1import { assert, beforeEach, it } from "vitest";
2import type { FilesystemBrowseResult } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 EMPTY_FILESYSTEM_BROWSE_STATE,
7 createFilesystemBrowseManager,
8} from "./filesystemBrowseState.ts";
9
10const ROOT_RESULT: FilesystemBrowseResult = {
11 parentPath: "/Users/julius",
12 entries: [
13 {
14 name: "code",
15 fullPath: "/Users/julius/code",
16 },
17 ],
18};
19
20let registry = AtomRegistry.make();
21
22beforeEach(() => {
23 registry.dispose();
24 registry = AtomRegistry.make();
25});
26
27function unresolvedBrowse() {
28 throw new Error("Browse resolver was not initialized.");
29}
30
31function flushAsyncWork(): Promise<void> {
32 return Promise.resolve().then(() => undefined);
33}
34
35it("stores browsed folder data in an atom snapshot", async () => {
36 const manager = createFilesystemBrowseManager({
37 getRegistry: () => registry,
38 getClient: () => ({
39 browse: async () => ROOT_RESULT,
40 }),
41 });
42
43 assert.deepStrictEqual(
44 manager.getSnapshot({ key: null, input: null }),
45 EMPTY_FILESYSTEM_BROWSE_STATE,
46 );
47
48 const target = { key: "env-1", input: { partialPath: "~" } };
49 const result = await manager.refresh(target);
50
51 assert.strictEqual(result, ROOT_RESULT);
52 assert.deepStrictEqual(manager.getSnapshot(target), {
53 data: ROOT_RESULT,
54 error: null,
55 isPending: false,
56 });
57});
58
59it("deduplicates in-flight browse refreshes by target input", async () => {
60 let resolveBrowse: (result: FilesystemBrowseResult) => void = unresolvedBrowse;
61 let calls = 0;
62 const target = { key: "env-1", input: { partialPath: "~" } };
63 const manager = createFilesystemBrowseManager({
64 getRegistry: () => registry,
65 getClient: () => ({
66 browse: () => {
67 calls += 1;
68 return new Promise<FilesystemBrowseResult>((resolve) => {
69 resolveBrowse = resolve;
70 });
71 },
72 }),
73 });
74
75 const first = manager.refresh(target);
76 const second = manager.refresh(target);
77
78 assert.strictEqual(first, second);
79 assert.strictEqual(calls, 1);
80 assert.deepStrictEqual(manager.getSnapshot(target), {
81 data: null,
82 error: null,
83 isPending: true,
84 });
85
86 resolveBrowse(ROOT_RESULT);
87 await first;
88
89 assert.deepStrictEqual(manager.getSnapshot(target), {
90 data: ROOT_RESULT,
91 error: null,
92 isPending: false,
93 });
94});
95
96it("keeps fresh watched browse results on remount", async () => {
97 let browseCalls = 0;
98 const target = { key: "env-1", input: { partialPath: "~" } };
99 const manager = createFilesystemBrowseManager({
100 getRegistry: () => registry,
101 getClient: () => ({
102 browse: async () => {
103 browseCalls += 1;
104 return ROOT_RESULT;
105 },
106 }),
107 staleTimeMs: 60_000,
108 });
109
110 const firstUnwatch = manager.watch(target);
111 await flushAsyncWork();
112 firstUnwatch();
113
114 const secondUnwatch = manager.watch(target);
115 await flushAsyncWork();
116 secondUnwatch();
117
118 assert.strictEqual(browseCalls, 1);
119});
packages/client-runtime/src/filesystemBrowseState.ts created+339
...@@ -0,0 +1,339 @@
1import type { FilesystemBrowseInput, FilesystemBrowseResult } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
4
5export interface FilesystemBrowseState {
6 readonly data: FilesystemBrowseResult | null;
7 readonly error: string | null;
8 readonly isPending: boolean;
9}
10
11export interface FilesystemBrowseTarget<TKey extends string = string> {
12 readonly key: TKey | null;
13 readonly input: FilesystemBrowseInput | null;
14}
15
16export interface FilesystemBrowseClient {
17 readonly browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
18}
19
20interface WatchedEntry {
21 refCount: number;
22 teardown: () => void;
23}
24
25export const EMPTY_FILESYSTEM_BROWSE_STATE = Object.freeze<FilesystemBrowseState>({
26 data: null,
27 error: null,
28 isPending: false,
29});
30
31const INITIAL_FILESYSTEM_BROWSE_STATE = Object.freeze<FilesystemBrowseState>({
32 data: null,
33 error: null,
34 isPending: true,
35});
36
37const knownFilesystemBrowseKeys = new Set<string>();
38
39export const filesystemBrowseStateAtom = Atom.family((targetKey: string) => {
40 knownFilesystemBrowseKeys.add(targetKey);
41 return Atom.make(INITIAL_FILESYSTEM_BROWSE_STATE).pipe(
42 Atom.keepAlive,
43 Atom.withLabel(`filesystem-browse:${targetKey}`),
44 );
45});
46
47export const EMPTY_FILESYSTEM_BROWSE_ATOM = Atom.make(EMPTY_FILESYSTEM_BROWSE_STATE).pipe(
48 Atom.keepAlive,
49 Atom.withLabel("filesystem-browse:null"),
50);
51
52const NOOP: () => void = () => undefined;
53const DEFAULT_STALE_TIME_MS = 30_000;
54const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
55
56export function getFilesystemBrowseTargetKey<TKey extends string>(
57 target: FilesystemBrowseTarget<TKey>,
58): string | null {
59 const key = target.key;
60 const input = target.input;
61 if (!key || !input || input.partialPath.length === 0) {
62 return null;
63 }
64
65 return JSON.stringify([key, input.cwd ?? null, input.partialPath]);
66}
67
68export interface FilesystemBrowseManagerConfig<TKey extends string = string> {
69 readonly getRegistry: () => AtomRegistry.AtomRegistry;
70 readonly getClient: (key: TKey) => FilesystemBrowseClient | null;
71 readonly subscribeClientChanges?: (listener: () => void) => () => void;
72 readonly staleTimeMs?: number;
73 readonly idleTtlMs?: number;
74}
75
76export function createFilesystemBrowseManager<TKey extends string = string>(
77 config: FilesystemBrowseManagerConfig<TKey>,
78) {
79 const refreshInFlight = new Map<
80 string,
81 {
82 readonly client: FilesystemBrowseClient;
83 readonly promise: Promise<FilesystemBrowseResult | null>;
84 }
85 >();
86 const refreshVersions = new Map<string, number>();
87 const watched = new Map<string, WatchedEntry>();
88 const refreshTargets = new Map<string, FilesystemBrowseTarget<TKey>>();
89 const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS;
90 const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
91
92 const watchedRefreshAtom = Atom.family((targetKey: string) =>
93 Atom.make(() =>
94 Effect.promise(() => {
95 const target = refreshTargets.get(targetKey);
96 return target ? refresh(target) : Promise.resolve(null);
97 }),
98 ).pipe(
99 Atom.swr({
100 staleTime: staleTimeMs,
101 revalidateOnMount: true,
102 }),
103 Atom.setIdleTTL(idleTtlMs),
104 Atom.withLabel(`filesystem-browse:watched-refresh:${targetKey}`),
105 ),
106 );
107
108 function getRefreshVersion(targetKey: string): number {
109 return refreshVersions.get(targetKey) ?? 0;
110 }
111
112 function bumpRefreshVersion(targetKey: string): void {
113 refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1);
114 }
115
116 function setState(targetKey: string, nextState: FilesystemBrowseState): void {
117 config.getRegistry().set(filesystemBrowseStateAtom(targetKey), nextState);
118 }
119
120 function markPending(targetKey: string): void {
121 const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey));
122 const next: FilesystemBrowseState =
123 current.data === null
124 ? INITIAL_FILESYSTEM_BROWSE_STATE
125 : {
126 data: current.data,
127 error: null,
128 isPending: true,
129 };
130
131 if (
132 current.data === next.data &&
133 current.error === next.error &&
134 current.isPending === next.isPending
135 ) {
136 return;
137 }
138
139 setState(targetKey, next);
140 }
141
142 function setData(targetKey: string, data: FilesystemBrowseResult): void {
143 setState(targetKey, {
144 data,
145 error: null,
146 isPending: false,
147 });
148 }
149
150 function setError(targetKey: string, error: unknown): void {
151 const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey));
152 setState(targetKey, {
153 data: current.data,
154 error: error instanceof Error ? error.message : "Failed to browse folder.",
155 isPending: false,
156 });
157 }
158
159 function refresh(
160 target: FilesystemBrowseTarget<TKey>,
161 client?: FilesystemBrowseClient,
162 ): Promise<FilesystemBrowseResult | null> {
163 const targetKey = getFilesystemBrowseTargetKey(target);
164 if (targetKey === null || target.key === null || target.input === null) {
165 return Promise.resolve(null);
166 }
167 refreshTargets.set(targetKey, target);
168
169 const resolvedClient = client ?? config.getClient(target.key);
170 if (!resolvedClient) {
171 setError(targetKey, new Error("Filesystem browser client is unavailable."));
172 return Promise.resolve(getSnapshot(target).data);
173 }
174
175 const existing = refreshInFlight.get(targetKey);
176 if (existing) {
177 if (!client || existing.client === resolvedClient) {
178 return existing.promise;
179 }
180 return existing.promise.then(() => refresh(target, resolvedClient));
181 }
182
183 markPending(targetKey);
184 const refreshVersion = getRefreshVersion(targetKey);
185 const promise = resolvedClient.browse(target.input).then(
186 (result) => {
187 if (getRefreshVersion(targetKey) === refreshVersion) {
188 setData(targetKey, result);
189 }
190 return result;
191 },
192 (error: unknown) => {
193 if (getRefreshVersion(targetKey) === refreshVersion) {
194 setError(targetKey, error);
195 }
196 return getSnapshot(target).data;
197 },
198 );
199
200 let tracked: Promise<FilesystemBrowseResult | null>;
201 tracked = promise.finally(() => {
202 if (refreshInFlight.get(targetKey)?.promise === tracked) {
203 refreshInFlight.delete(targetKey);
204 }
205 });
206 refreshInFlight.set(targetKey, {
207 client: resolvedClient,
208 promise: tracked,
209 });
210 return tracked;
211 }
212
213 function invalidate(target?: FilesystemBrowseTarget<TKey>): void {
214 if (!target) {
215 reset();
216 return;
217 }
218
219 const targetKey = getFilesystemBrowseTargetKey(target);
220 if (targetKey === null) {
221 return;
222 }
223
224 bumpRefreshVersion(targetKey);
225 refreshInFlight.delete(targetKey);
226 setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE);
227 }
228
229 function getSnapshot(target: FilesystemBrowseTarget<TKey>): FilesystemBrowseState {
230 const targetKey = getFilesystemBrowseTargetKey(target);
231 if (targetKey === null) {
232 return EMPTY_FILESYSTEM_BROWSE_STATE;
233 }
234
235 return config.getRegistry().get(filesystemBrowseStateAtom(targetKey));
236 }
237
238 function watch(
239 target: FilesystemBrowseTarget<TKey>,
240 client?: FilesystemBrowseClient,
241 ): () => void {
242 const targetKey = getFilesystemBrowseTargetKey(target);
243 if (targetKey === null || target.key === null) {
244 return NOOP;
245 }
246 refreshTargets.set(targetKey, target);
247
248 const existing = watched.get(targetKey);
249 if (existing) {
250 existing.refCount += 1;
251 return () => unwatch(targetKey);
252 }
253
254 let teardown: () => void;
255
256 if (client) {
257 void refresh(target, client);
258 teardown = NOOP;
259 } else if (config.subscribeClientChanges) {
260 let currentClient: FilesystemBrowseClient | null = null;
261
262 const sync = () => {
263 const resolved = config.getClient(target.key!);
264 if (!resolved) {
265 currentClient = null;
266 markPending(targetKey);
267 return;
268 }
269
270 if (currentClient === resolved) {
271 return;
272 }
273
274 const isClientReplacement = currentClient !== null;
275 currentClient = resolved;
276 refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined);
277 };
278
279 const unsubChanges = config.subscribeClientChanges(sync);
280 sync();
281 teardown = unsubChanges;
282 } else {
283 if (!config.getClient(target.key)) {
284 return NOOP;
285 }
286 refreshWatchedTarget(targetKey, target);
287 teardown = NOOP;
288 }
289
290 watched.set(targetKey, { refCount: 1, teardown });
291 return () => unwatch(targetKey);
292 }
293
294 function unwatch(targetKey: string): void {
295 const entry = watched.get(targetKey);
296 if (!entry) {
297 return;
298 }
299
300 entry.refCount -= 1;
301 if (entry.refCount > 0) {
302 return;
303 }
304
305 entry.teardown();
306 watched.delete(targetKey);
307 }
308
309 function refreshWatchedTarget(
310 targetKey: string,
311 target: FilesystemBrowseTarget<TKey>,
312 client?: FilesystemBrowseClient,
313 ): void {
314 refreshTargets.set(targetKey, target);
315 const registry = config.getRegistry();
316 void registry.get(watchedRefreshAtom(targetKey));
317 if (client) {
318 void refresh(target, client);
319 }
320 }
321
322 function reset(): void {
323 refreshInFlight.clear();
324 watched.clear();
325 refreshTargets.clear();
326 for (const targetKey of knownFilesystemBrowseKeys) {
327 bumpRefreshVersion(targetKey);
328 setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE);
329 }
330 }
331
332 return {
333 refresh,
334 invalidate,
335 getSnapshot,
336 watch,
337 reset,
338 };
339}
packages/client-runtime/src/gitActions.ts created+440
...@@ -0,0 +1,440 @@
1import type {
2 GitRunStackedActionInput,
3 GitRunStackedActionResult,
4 GitStackedAction,
5 VcsStatusResult,
6} from "@t3tools/contracts";
7import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
8
9export type GitActionIconName = "commit" | "push" | "pr";
10
11export type GitDialogAction = "commit" | "push" | "create_pr";
12
13export interface GitActionMenuItem {
14 id: "commit" | "push" | "pr";
15 label: string;
16 disabled: boolean;
17 icon: GitActionIconName;
18 kind: "open_dialog" | "open_pr";
19 dialogAction?: GitDialogAction;
20}
21
22export interface GitQuickAction {
23 label: string;
24 disabled: boolean;
25 kind: "run_action" | "run_pull" | "open_pr" | "show_hint";
26 action?: GitStackedAction;
27 hint?: string;
28}
29
30export interface DefaultBranchActionDialogCopy {
31 title: string;
32 description: string;
33 continueLabel: string;
34}
35
36export type DefaultBranchConfirmableAction =
37 | "push"
38 | "create_pr"
39 | "commit_push"
40 | "commit_push_pr";
41
42export type GitActionRequestInput = Pick<
43 GitRunStackedActionInput,
44 "action" | "commitMessage" | "featureBranch" | "filePaths"
45>;
46
47export function buildGitActionProgressStages(input: {
48 action: GitStackedAction;
49 hasCustomCommitMessage: boolean;
50 hasWorkingTreeChanges: boolean;
51 pushTarget?: string;
52 featureBranch?: boolean;
53 shouldPushBeforePr?: boolean;
54}): string[] {
55 const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];
56 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
57 const prStages = [
58 "Preparing PR...",
59 "Generating PR content...",
60 "Creating GitHub pull request...",
61 ];
62
63 if (input.action === "push") {
64 return [pushStage];
65 }
66 if (input.action === "create_pr") {
67 return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages;
68 }
69
70 const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges;
71 const commitStages = !shouldIncludeCommitStages
72 ? []
73 : input.hasCustomCommitMessage
74 ? ["Committing..."]
75 : ["Generating commit message...", "Committing..."];
76 if (input.action === "commit") {
77 return [...branchStages, ...commitStages];
78 }
79 if (input.action === "commit_push") {
80 return [...branchStages, ...commitStages, pushStage];
81 }
82 return [...branchStages, ...commitStages, pushStage, ...prStages];
83}
84
85export function buildMenuItems(
86 gitStatus: VcsStatusResult | null,
87 isBusy: boolean,
88 hasOriginRemote = true,
89): GitActionMenuItem[] {
90 if (!gitStatus) return [];
91
92 const hasBranch = gitStatus.refName !== null;
93 const hasChanges = gitStatus.hasWorkingTreeChanges;
94 const hasOpenPr = gitStatus.pr?.state === "open";
95 const isBehind = gitStatus.behindCount > 0;
96 const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream;
97 const canCommit = !isBusy && hasChanges;
98 const canPush =
99 !isBusy &&
100 hasBranch &&
101 !hasChanges &&
102 !isBehind &&
103 gitStatus.aheadCount > 0 &&
104 (gitStatus.hasUpstream || canPushWithoutUpstream);
105 const canCreatePr =
106 !isBusy &&
107 hasBranch &&
108 !hasChanges &&
109 !hasOpenPr &&
110 gitStatus.aheadCount > 0 &&
111 !isBehind &&
112 (gitStatus.hasUpstream || canPushWithoutUpstream);
113 const canOpenPr = !isBusy && hasOpenPr;
114
115 return [
116 {
117 id: "commit",
118 label: "Commit",
119 disabled: !canCommit,
120 icon: "commit",
121 kind: "open_dialog",
122 dialogAction: "commit",
123 },
124 {
125 id: "push",
126 label: "Push",
127 disabled: !canPush,
128 icon: "push",
129 kind: "open_dialog",
130 dialogAction: "push",
131 },
132 hasOpenPr
133 ? {
134 id: "pr",
135 label: "View PR",
136 disabled: !canOpenPr,
137 icon: "pr",
138 kind: "open_pr",
139 }
140 : {
141 id: "pr",
142 label: "Create PR",
143 disabled: !canCreatePr,
144 icon: "pr",
145 kind: "open_dialog",
146 dialogAction: "create_pr",
147 },
148 ];
149}
150
151export function resolveQuickAction(
152 gitStatus: VcsStatusResult | null,
153 isBusy: boolean,
154 isDefaultBranch = false,
155 hasOriginRemote = true,
156): GitQuickAction {
157 if (isBusy) {
158 return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
159 }
160
161 if (!gitStatus) {
162 return {
163 label: "Commit",
164 disabled: true,
165 kind: "show_hint",
166 hint: "Git status is unavailable.",
167 };
168 }
169
170 const hasBranch = gitStatus.refName !== null;
171 const hasChanges = gitStatus.hasWorkingTreeChanges;
172 const hasOpenPr = gitStatus.pr?.state === "open";
173 const isAhead = gitStatus.aheadCount > 0;
174 const isBehind = gitStatus.behindCount > 0;
175 const isDiverged = isAhead && isBehind;
176
177 if (!hasBranch) {
178 return {
179 label: "Commit",
180 disabled: true,
181 kind: "show_hint",
182 hint: "Create and checkout a branch before pushing or opening a PR.",
183 };
184 }
185
186 if (hasChanges) {
187 if (!gitStatus.hasUpstream && !hasOriginRemote) {
188 return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
189 }
190 if (hasOpenPr || isDefaultBranch) {
191 return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
192 }
193 return {
194 label: "Commit, push & PR",
195 disabled: false,
196 kind: "run_action",
197 action: "commit_push_pr",
198 };
199 }
200
201 if (!gitStatus.hasUpstream) {
202 if (!hasOriginRemote) {
203 if (hasOpenPr && !isAhead) {
204 return { label: "View PR", disabled: false, kind: "open_pr" };
205 }
206 return {
207 label: "Push",
208 disabled: true,
209 kind: "show_hint",
210 hint: 'Add an "origin" remote before pushing or creating a PR.',
211 };
212 }
213 if (!isAhead) {
214 if (hasOpenPr) {
215 return { label: "View PR", disabled: false, kind: "open_pr" };
216 }
217 return {
218 label: "Push",
219 disabled: true,
220 kind: "show_hint",
221 hint: "No local commits to push.",
222 };
223 }
224 if (hasOpenPr || isDefaultBranch) {
225 return {
226 label: "Push",
227 disabled: false,
228 kind: "run_action",
229 action: isDefaultBranch ? "commit_push" : "push",
230 };
231 }
232 return {
233 label: "Push & create PR",
234 disabled: false,
235 kind: "run_action",
236 action: "create_pr",
237 };
238 }
239
240 if (isDiverged) {
241 return {
242 label: "Sync branch",
243 disabled: true,
244 kind: "show_hint",
245 hint: "Branch has diverged from upstream. Rebase/merge first.",
246 };
247 }
248
249 if (isBehind) {
250 return {
251 label: "Pull",
252 disabled: false,
253 kind: "run_pull",
254 };
255 }
256
257 if (isAhead) {
258 if (hasOpenPr || isDefaultBranch) {
259 return {
260 label: "Push",
261 disabled: false,
262 kind: "run_action",
263 action: isDefaultBranch ? "commit_push" : "push",
264 };
265 }
266 return {
267 label: "Push & create PR",
268 disabled: false,
269 kind: "run_action",
270 action: "create_pr",
271 };
272 }
273
274 if (hasOpenPr && gitStatus.hasUpstream) {
275 return { label: "View PR", disabled: false, kind: "open_pr" };
276 }
277
278 return {
279 label: "Commit",
280 disabled: true,
281 kind: "show_hint",
282 hint: "Branch is up to date. No action needed.",
283 };
284}
285
286export function getGitActionDisabledReason(input: {
287 item: GitActionMenuItem;
288 gitStatus: VcsStatusResult | null;
289 isBusy: boolean;
290 hasOriginRemote: boolean;
291}): string | null {
292 const { item, gitStatus, isBusy, hasOriginRemote } = input;
293 if (!item.disabled) return null;
294 if (isBusy) return "Git action in progress.";
295 if (!gitStatus) return "Git status is unavailable.";
296
297 const hasBranch = gitStatus.refName !== null;
298 const hasChanges = gitStatus.hasWorkingTreeChanges;
299 const hasOpenPr = gitStatus.pr?.state === "open";
300 const isAhead = gitStatus.aheadCount > 0;
301 const isBehind = gitStatus.behindCount > 0;
302
303 if (item.id === "commit") {
304 if (!hasChanges) {
305 return "Worktree is clean. Make changes before committing.";
306 }
307 return "Commit is currently unavailable.";
308 }
309
310 if (item.id === "push") {
311 if (!hasBranch) {
312 return "Detached HEAD: checkout a branch before pushing.";
313 }
314 if (hasChanges) {
315 return "Commit or stash local changes before pushing.";
316 }
317 if (isBehind) {
318 return "Branch is behind upstream. Pull/rebase before pushing.";
319 }
320 if (!gitStatus.hasUpstream && !hasOriginRemote) {
321 return 'Add an "origin" remote before pushing.';
322 }
323 if (!isAhead) {
324 return "No local commits to push.";
325 }
326 return "Push is currently unavailable.";
327 }
328
329 if (hasOpenPr) {
330 return "View PR is currently unavailable.";
331 }
332 if (!hasBranch) {
333 return "Detached HEAD: checkout a branch before creating a PR.";
334 }
335 if (hasChanges) {
336 return "Commit local changes before creating a PR.";
337 }
338 if (!gitStatus.hasUpstream && !hasOriginRemote) {
339 return 'Add an "origin" remote before creating a PR.';
340 }
341 if (!isAhead) {
342 return "No local commits to include in a PR.";
343 }
344 if (isBehind) {
345 return "Branch is behind upstream. Pull/rebase before creating a PR.";
346 }
347 return "Create PR is currently unavailable.";
348}
349
350export function requiresDefaultBranchConfirmation(
351 action: GitStackedAction,
352 isDefaultBranch: boolean,
353): boolean {
354 if (!isDefaultBranch) return false;
355 return (
356 action === "push" ||
357 action === "create_pr" ||
358 action === "commit_push" ||
359 action === "commit_push_pr"
360 );
361}
362
363export function resolveDefaultBranchActionDialogCopy(input: {
364 action: DefaultBranchConfirmableAction;
365 branchName: string;
366 includesCommit: boolean;
367}): DefaultBranchActionDialogCopy {
368 const branchLabel = input.branchName;
369 const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`;
370
371 if (input.action === "push" || input.action === "commit_push") {
372 if (input.includesCommit) {
373 return {
374 title: "Commit & push to default branch?",
375 description: `This action will commit and push changes${suffix}`,
376 continueLabel: `Commit & push to ${branchLabel}`,
377 };
378 }
379 return {
380 title: "Push to default branch?",
381 description: `This action will push local commits${suffix}`,
382 continueLabel: `Push to ${branchLabel}`,
383 };
384 }
385
386 if (input.includesCommit) {
387 return {
388 title: "Commit, push & create PR from default branch?",
389 description: `This action will commit, push, and create a PR${suffix}`,
390 continueLabel: "Commit, push & create PR",
391 };
392 }
393 return {
394 title: "Push & create PR from default branch?",
395 description: `This action will push local commits and create a PR${suffix}`,
396 continueLabel: "Push & create PR",
397 };
398}
399
400export function resolveThreadBranchUpdate(
401 result: GitRunStackedActionResult,
402): { branch: string } | null {
403 if (result.branch.status !== "created" || !result.branch.name) {
404 return null;
405 }
406
407 return {
408 branch: result.branch.name,
409 };
410}
411
412export function resolveLiveThreadBranchUpdate(input: {
413 threadBranch: string | null;
414 gitStatus: VcsStatusResult | null;
415}): { branch: string | null } | null {
416 if (!input.gitStatus) {
417 return null;
418 }
419
420 if (input.gitStatus.refName === null && input.threadBranch !== null) {
421 return null;
422 }
423
424 if (input.threadBranch === input.gitStatus.refName) {
425 return null;
426 }
427
428 if (
429 input.threadBranch !== null &&
430 input.gitStatus.refName !== null &&
431 !isTemporaryWorktreeBranch(input.threadBranch) &&
432 isTemporaryWorktreeBranch(input.gitStatus.refName)
433 ) {
434 return null;
435 }
436
437 return {
438 branch: input.gitStatus.refName,
439 };
440}
packages/client-runtime/src/index.ts+24
...@@ -1,4 +1,28 @@...@@ -1,4 +1,28 @@
1export * from "./advertisedEndpoint.ts";1export * from "./advertisedEndpoint.ts";
2export * from "./knownEnvironment.ts";2export * from "./knownEnvironment.ts";
3export * from "./reconnectBackoff.ts";
3export * from "./scoped.ts";4export * from "./scoped.ts";
5export * from "./projectPaths.ts";
6export * from "./addProject.ts";
7export * from "./filesystemBrowseState.ts";
4export * from "./sourceControlDiscoveryState.ts";8export * from "./sourceControlDiscoveryState.ts";
9export * from "./environmentRuntimeState.ts";
10export * from "./shellTypes.ts";
11export * from "./shellSnapshotReducer.ts";
12export * from "./shellSnapshotState.ts";
13export * from "./threadDetailReducer.ts";
14export * from "./threadDetailState.ts";
15export * from "./gitActions.ts";
16export * from "./vcsActionState.ts";
17export * from "./vcsRefState.ts";
18export * from "./vcsStatusState.ts";
19export * from "./terminalSessionState.ts";
20export * from "./transportError.ts";
21export * from "./wsRpcProtocol.ts";
22export * from "./wsTransport.ts";
23export * from "./wsRpcClient.ts";
24export * from "./environmentConnection.ts";
25export * from "./composerPathSearchState.ts";
26export * from "./archivedThreadsState.ts";
27export * from "./checkpointDiffState.ts";
28export * from "./remote.ts";
packages/client-runtime/src/projectPaths.ts created+221
...@@ -0,0 +1,221 @@
1import {
2 isExplicitRelativePath,
3 isUncPath,
4 isWindowsAbsolutePath,
5 isWindowsDrivePath,
6} from "@t3tools/shared/path";
7
8function isWindowsPlatform(platform: string): boolean {
9 return /^win(dows)?/i.test(platform);
10}
11
12function isRootPath(value: string): boolean {
13 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
14}
15
16function getAbsolutePathKind(value: string): "unix" | "windows" | null {
17 if (isWindowsDrivePath(value) || isUncPath(value)) {
18 return "windows";
19 }
20 if (value.startsWith("/")) {
21 return "unix";
22 }
23 return null;
24}
25
26function trimTrailingPathSeparators(value: string): string {
27 if (value.length === 0 || isRootPath(value)) {
28 return value;
29 }
30 const trimmed =
31 getAbsolutePathKind(value) === "unix"
32 ? value.replace(/\/+$/g, "")
33 : value.replace(/[\\/]+$/g, "");
34 if (trimmed.length === 0) {
35 return value;
36 }
37 return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
38}
39
40function preferredPathSeparator(value: string): "/" | "\\" {
41 const absolutePathKind = getAbsolutePathKind(value);
42 if (absolutePathKind === "windows") return "\\";
43 if (absolutePathKind === "unix") return "/";
44 return value.includes("\\") ? "\\" : "/";
45}
46
47export function hasTrailingPathSeparator(value: string): boolean {
48 return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value);
49}
50
51export { isExplicitRelativePath as isExplicitRelativeProjectPath };
52
53function splitPathSegments(value: string, separator: "/" | "\\"): string[] {
54 return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean);
55}
56
57function getLastPathSeparatorIndex(value: string): number {
58 if (getAbsolutePathKind(value) === "unix") {
59 return value.lastIndexOf("/");
60 }
61 return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
62}
63
64function splitAbsolutePath(value: string): {
65 root: string;
66 separator: "/" | "\\";
67 segments: string[];
68} | null {
69 if (isWindowsDrivePath(value)) {
70 const root = `${value.slice(0, 2)}\\`;
71 const segments = splitPathSegments(value.slice(root.length), "\\");
72 return { root, separator: "\\", segments };
73 }
74 if (isUncPath(value)) {
75 const segments = splitPathSegments(value, "\\");
76 const [server, share, ...rest] = segments;
77 if (!server || !share) return null;
78 return {
79 root: `\\\\${server}\\${share}\\`,
80 separator: "\\",
81 segments: rest,
82 };
83 }
84 if (value.startsWith("/")) {
85 return {
86 root: "/",
87 separator: "/",
88 segments: splitPathSegments(value.slice(1), "/"),
89 };
90 }
91 return null;
92}
93
94export function isFilesystemBrowseQuery(value: string, platform = ""): boolean {
95 const allowWindowsPaths = isWindowsPlatform(platform);
96 return (
97 value.startsWith("./") ||
98 value.startsWith("../") ||
99 value.startsWith(".\\") ||
100 value.startsWith("..\\") ||
101 value.startsWith("/") ||
102 value.startsWith("~/") ||
103 (allowWindowsPaths && isWindowsAbsolutePath(value))
104 );
105}
106
107export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean {
108 return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
109}
110
111export function normalizeProjectPathForDispatch(value: string): string {
112 return trimTrailingPathSeparators(value.trim());
113}
114
115export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {
116 const trimmedValue = value.trim();
117 if (!isExplicitRelativePath(trimmedValue) || !cwd) {
118 return normalizeProjectPathForDispatch(trimmedValue);
119 }
120
121 const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd));
122 if (!absoluteBase) {
123 return normalizeProjectPathForDispatch(trimmedValue);
124 }
125
126 const nextSegments = [...absoluteBase.segments];
127 for (const segment of trimmedValue.split(/[\\/]+/)) {
128 if (segment.length === 0 || segment === ".") continue;
129 if (segment === "..") {
130 nextSegments.pop();
131 continue;
132 }
133 nextSegments.push(segment);
134 }
135
136 const joinedPath = nextSegments.join(absoluteBase.separator);
137 return normalizeProjectPathForDispatch(
138 joinedPath.length === 0 ? absoluteBase.root : `${absoluteBase.root}${joinedPath}`,
139 );
140}
141
142export function normalizeProjectPathForComparison(value: string): string {
143 const normalized = normalizeProjectPathForDispatch(value);
144 if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) {
145 return normalized.replaceAll("/", "\\").toLowerCase();
146 }
147 return normalized;
148}
149
150export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>(
151 projects: ReadonlyArray<T>,
152 candidatePath: string,
153): T | undefined {
154 const normalizedCandidate = normalizeProjectPathForComparison(candidatePath);
155 if (normalizedCandidate.length === 0) {
156 return undefined;
157 }
158 return projects.find((project) => {
159 const cwd = project.workspaceRoot ?? project.cwd;
160 return cwd ? normalizeProjectPathForComparison(cwd) === normalizedCandidate : false;
161 });
162}
163
164export function inferProjectTitleFromPath(value: string): string {
165 const normalized = normalizeProjectPathForDispatch(value);
166 const absolutePath = splitAbsolutePath(normalized);
167 if (absolutePath) {
168 return absolutePath.segments.findLast(Boolean) ?? normalized;
169 }
170 const segments = normalized.split(/[/\\]/);
171 return segments.findLast(Boolean) ?? normalized;
172}
173
174export function appendBrowsePathSegment(currentPath: string, segment: string): string {
175 const separator = preferredPathSeparator(currentPath);
176 return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`;
177}
178
179export function getBrowseLeafPathSegment(currentPath: string): string {
180 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
181 return currentPath.slice(lastSeparatorIndex + 1);
182}
183
184export function getBrowseDirectoryPath(currentPath: string): string {
185 if (hasTrailingPathSeparator(currentPath)) {
186 return currentPath;
187 }
188 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
189 return lastSeparatorIndex < 0 ? currentPath : currentPath.slice(0, lastSeparatorIndex + 1);
190}
191
192export function ensureBrowseDirectoryPath(currentPath: string): string {
193 const trimmed = currentPath.trim();
194 if (trimmed.length === 0 || hasTrailingPathSeparator(trimmed)) {
195 return trimmed;
196 }
197 return `${trimmed}${preferredPathSeparator(trimmed)}`;
198}
199
200export function getBrowseParentPath(currentPath: string): string | null {
201 const trimmed = trimTrailingPathSeparators(currentPath);
202 const absolutePath = splitAbsolutePath(trimmed);
203 if (absolutePath) {
204 if (absolutePath.segments.length === 0) return null;
205 if (absolutePath.segments.length === 1) return absolutePath.root;
206 const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator);
207 return `${absolutePath.root}${parentSegments}${absolutePath.separator}`;
208 }
209
210 const separator = preferredPathSeparator(currentPath);
211 const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed);
212 if (lastSeparatorIndex < 0) return null;
213 if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) {
214 return `${trimmed.slice(0, 2)}${separator}`;
215 }
216 return trimmed.slice(0, lastSeparatorIndex + 1);
217}
218
219export function canNavigateUp(currentPath: string): boolean {
220 return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null;
221}
packages/client-runtime/src/reconnectBackoff.test.ts created+65
...@@ -0,0 +1,65 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 DEFAULT_RECONNECT_BACKOFF,
5 getReconnectDelayMs,
6 type ReconnectBackoffConfig,
7} from "./reconnectBackoff.ts";
8
9describe("getReconnectDelayMs", () => {
10 it("returns exponential delays with default config", () => {
11 expect(getReconnectDelayMs(0)).toBe(1_000);
12 expect(getReconnectDelayMs(1)).toBe(2_000);
13 expect(getReconnectDelayMs(2)).toBe(4_000);
14 expect(getReconnectDelayMs(3)).toBe(8_000);
15 expect(getReconnectDelayMs(4)).toBe(16_000);
16 expect(getReconnectDelayMs(5)).toBe(32_000);
17 expect(getReconnectDelayMs(6)).toBe(64_000);
18 });
19
20 it("returns null when retry index exceeds maxRetries", () => {
21 expect(getReconnectDelayMs(7)).toBeNull();
22 expect(getReconnectDelayMs(100)).toBeNull();
23 });
24
25 it("returns null for negative indices", () => {
26 expect(getReconnectDelayMs(-1)).toBeNull();
27 });
28
29 it("returns null for non-integer indices", () => {
30 expect(getReconnectDelayMs(1.5)).toBeNull();
31 });
32
33 it("caps delay at maxDelayMs", () => {
34 const config: ReconnectBackoffConfig = {
35 initialDelayMs: 10_000,
36 backoffFactor: 10,
37 maxDelayMs: 30_000,
38 maxRetries: 5,
39 };
40
41 expect(getReconnectDelayMs(0, config)).toBe(10_000);
42 expect(getReconnectDelayMs(1, config)).toBe(30_000); // 100_000 capped to 30_000
43 expect(getReconnectDelayMs(2, config)).toBe(30_000); // 1_000_000 capped to 30_000
44 });
45
46 it("supports unlimited retries when maxRetries is null", () => {
47 const config: ReconnectBackoffConfig = {
48 ...DEFAULT_RECONNECT_BACKOFF,
49 maxRetries: null,
50 };
51
52 expect(getReconnectDelayMs(0, config)).toBe(1_000);
53 expect(getReconnectDelayMs(50, config)).toBe(64_000); // capped at maxDelayMs
54 expect(getReconnectDelayMs(100, config)).toBe(64_000);
55 });
56});
57
58describe("DEFAULT_RECONNECT_BACKOFF", () => {
59 it("has sensible defaults", () => {
60 expect(DEFAULT_RECONNECT_BACKOFF.initialDelayMs).toBe(1_000);
61 expect(DEFAULT_RECONNECT_BACKOFF.backoffFactor).toBe(2);
62 expect(DEFAULT_RECONNECT_BACKOFF.maxDelayMs).toBe(64_000);
63 expect(DEFAULT_RECONNECT_BACKOFF.maxRetries).toBe(7);
64 });
65});
packages/client-runtime/src/reconnectBackoff.ts created+47
...@@ -0,0 +1,47 @@
1/**
2 * Configuration for exponential reconnect backoff.
3 */
4export interface ReconnectBackoffConfig {
5 /** Base delay in milliseconds before the first retry. */
6 readonly initialDelayMs: number;
7 /** Multiplier applied per retry (exponential factor). */
8 readonly backoffFactor: number;
9 /** Hard upper bound on delay in milliseconds. */
10 readonly maxDelayMs: number;
11 /** Maximum number of retries (0-based). `null` means unlimited. */
12 readonly maxRetries: number | null;
13}
14
15/**
16 * Sensible defaults for WebSocket reconnect backoff.
17 *
18 * - 1 s initial delay, doubling each retry, capped at 64 s, up to 7 retries.
19 */
20export const DEFAULT_RECONNECT_BACKOFF: ReconnectBackoffConfig = {
21 initialDelayMs: 1_000,
22 backoffFactor: 2,
23 maxDelayMs: 64_000,
24 maxRetries: 7,
25};
26
27/**
28 * Calculate the reconnect delay for a given retry index using exponential
29 * backoff. Returns `null` when `retryIndex` exceeds the configured maximum.
30 */
31export function getReconnectDelayMs(
32 retryIndex: number,
33 config: ReconnectBackoffConfig = DEFAULT_RECONNECT_BACKOFF,
34): number | null {
35 if (!Number.isInteger(retryIndex) || retryIndex < 0) {
36 return null;
37 }
38
39 if (config.maxRetries !== null && retryIndex >= config.maxRetries) {
40 return null;
41 }
42
43 return Math.min(
44 Math.round(config.initialDelayMs * config.backoffFactor ** retryIndex),
45 config.maxDelayMs,
46 );
47}
packages/client-runtime/src/remote.test.ts created+251
...@@ -0,0 +1,251 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as TestClock from "effect/testing/TestClock";
6import { HttpClient } from "effect/unstable/http";
7
8import {
9 bootstrapRemoteBearerSession,
10 fetchRemoteEnvironmentDescriptor,
11 fetchRemoteSessionState,
12 issueRemoteWebSocketToken,
13 remoteHttpClientLayer,
14 RemoteEnvironmentAuthTimeoutError,
15 resolveRemoteWebSocketConnectionUrl,
16} from "./remote.ts";
17
18type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit];
19
20const recordedFetch = (...responses: ReadonlyArray<Response>) => {
21 const calls: Array<FetchCall> = [];
22 let responseIndex = 0;
23 const fetchFn = ((input, init) => {
24 calls.push([input, init ?? {}]);
25 const response = responses[responseIndex++];
26 if (!response) {
27 return Promise.reject(new Error("Unexpected fetch call"));
28 }
29 return Promise.resolve(response);
30 }) satisfies typeof fetch;
31
32 return { fetchFn, calls };
33};
34
35const hangingFetch = () => {
36 const calls: Array<FetchCall> = [];
37 const fetchFn = ((input, init) => {
38 calls.push([input, init ?? {}]);
39 return new Promise<Response>(() => undefined);
40 }) satisfies typeof fetch;
41
42 return { fetchFn, calls };
43};
44
45const provideRemoteHttp =
46 (fetchFn: typeof fetch) =>
47 <A, E, R>(effect: Effect.Effect<A, E, R | HttpClient.HttpClient>) =>
48 effect.pipe(Effect.provide(remoteHttpClientLayer(fetchFn)));
49
50const expectFetchCall = (
51 calls: ReadonlyArray<FetchCall>,
52 index: number,
53 expected: {
54 readonly url: string;
55 readonly method: string;
56 readonly headers?: Record<string, string>;
57 readonly body?: string;
58 },
59): void => {
60 const call = calls[index - 1];
61 expect(call).toBeDefined();
62 if (!call) {
63 return;
64 }
65
66 const [url, init] = call;
67 expect(String(url)).toBe(expected.url);
68 expect(init).toEqual(
69 expect.objectContaining({
70 method: expected.method,
71 }),
72 );
73 expect(init.headers).toEqual(expect.objectContaining(expected.headers ?? {}));
74
75 if ("body" in expected) {
76 const body = init.body;
77 if (typeof body === "string") {
78 expect(body).toBe(expected.body);
79 } else if (body instanceof Uint8Array) {
80 expect(new TextDecoder().decode(body)).toBe(expected.body);
81 } else {
82 throw new Error("Expected fetch request body");
83 }
84 }
85};
86
87describe("remote", () => {
88 it.effect("bootstraps bearer auth against a remote backend", () =>
89 Effect.gen(function* () {
90 const fetch = recordedFetch(
91 Response.json(
92 {
93 authenticated: true,
94 role: "client",
95 sessionMethod: "bearer-session-token",
96 expiresAt: "2026-05-01T12:00:00.000Z",
97 sessionToken: "bearer-token",
98 },
99 { status: 200 },
100 ),
101 );
102
103 const result = yield* bootstrapRemoteBearerSession({
104 httpBaseUrl: "https://remote.example.com/",
105 credential: "pairing-token",
106 }).pipe(provideRemoteHttp(fetch.fetchFn));
107
108 expect(result).toMatchObject({
109 sessionMethod: "bearer-session-token",
110 sessionToken: "bearer-token",
111 });
112 expectFetchCall(fetch.calls, 1, {
113 url: "https://remote.example.com/api/auth/bootstrap/bearer",
114 method: "POST",
115 headers: {
116 "content-type": "application/json",
117 },
118 body: `{"credential":"pairing-token"}`,
119 });
120 }),
121 );
122
123 it.effect("loads remote session state and websocket tokens over bearer auth", () =>
124 Effect.gen(function* () {
125 const fetch = recordedFetch(
126 Response.json(
127 {
128 environmentId: "environment-remote",
129 label: "Remote environment",
130 platform: {
131 os: "linux",
132 arch: "x64",
133 },
134 serverVersion: "0.0.0-test",
135 capabilities: {
136 repositoryIdentity: true,
137 },
138 },
139 { status: 200 },
140 ),
141 Response.json(
142 {
143 authenticated: true,
144 auth: {
145 policy: "remote-reachable",
146 bootstrapMethods: ["one-time-token"],
147 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
148 sessionCookieName: "t3_session",
149 },
150 role: "client",
151 sessionMethod: "bearer-session-token",
152 expiresAt: "2026-05-01T12:00:00.000Z",
153 },
154 { status: 200 },
155 ),
156 Response.json(
157 {
158 token: "ws-token",
159 expiresAt: "2026-05-01T12:05:00.000Z",
160 },
161 { status: 200 },
162 ),
163 );
164
165 const environment = yield* fetchRemoteEnvironmentDescriptor({
166 httpBaseUrl: "https://remote.example.com/",
167 }).pipe(provideRemoteHttp(fetch.fetchFn));
168 expect(environment).toMatchObject({
169 environmentId: "environment-remote",
170 label: "Remote environment",
171 });
172
173 const session = yield* fetchRemoteSessionState({
174 httpBaseUrl: "https://remote.example.com/",
175 bearerToken: "bearer-token",
176 }).pipe(provideRemoteHttp(fetch.fetchFn));
177 expect(session).toMatchObject({
178 authenticated: true,
179 role: "client",
180 });
181
182 const token = yield* issueRemoteWebSocketToken({
183 httpBaseUrl: "https://remote.example.com/",
184 bearerToken: "bearer-token",
185 }).pipe(provideRemoteHttp(fetch.fetchFn));
186 expect(token).toMatchObject({
187 token: "ws-token",
188 });
189
190 expectFetchCall(fetch.calls, 1, {
191 url: "https://remote.example.com/.well-known/t3/environment",
192 method: "GET",
193 });
194 expectFetchCall(fetch.calls, 2, {
195 url: "https://remote.example.com/api/auth/session",
196 method: "GET",
197 headers: {
198 authorization: "Bearer bearer-token",
199 },
200 });
201 expectFetchCall(fetch.calls, 3, {
202 url: "https://remote.example.com/api/auth/ws-token",
203 method: "POST",
204 headers: {
205 authorization: "Bearer bearer-token",
206 },
207 });
208 }),
209 );
210
211 it.effect("fails hung fetch requests on the configured timeout", () =>
212 Effect.gen(function* () {
213 const fetch = hangingFetch();
214
215 const errorFiber = yield* fetchRemoteEnvironmentDescriptor({
216 httpBaseUrl: "http://remote.example.com/",
217 timeoutMs: 25,
218 }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip, Effect.forkScoped);
219 yield* Effect.yieldNow;
220 yield* TestClock.adjust(Duration.millis(25));
221 const error = yield* Fiber.join(errorFiber);
222
223 expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError);
224 expect(error.message).toBe(
225 "Remote auth endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.",
226 );
227 }).pipe(Effect.provide(TestClock.layer())),
228 );
229
230 it.effect("mints a websocket url that targets the rpc route with a short-lived ws token", () =>
231 Effect.gen(function* () {
232 const fetch = recordedFetch(
233 Response.json(
234 {
235 token: "ws-token",
236 expiresAt: "2026-05-01T12:05:00.000Z",
237 },
238 { status: 200 },
239 ),
240 );
241
242 const url = yield* resolveRemoteWebSocketConnectionUrl({
243 wsBaseUrl: "wss://remote.example.com/",
244 httpBaseUrl: "https://remote.example.com/",
245 bearerToken: "bearer-token",
246 }).pipe(provideRemoteHttp(fetch.fetchFn));
247
248 expect(url).toBe("wss://remote.example.com/ws?wsToken=ws-token");
249 }),
250 );
251});
packages/client-runtime/src/remote.ts created+269
...@@ -0,0 +1,269 @@
1import type {
2 AuthBearerBootstrapResult,
3 AuthSessionState,
4 AuthWebSocketTokenResult,
5 ExecutionEnvironmentDescriptor,
6} from "@t3tools/contracts";
7import { decodeJsonResult } from "@t3tools/shared/schemaJson";
8import * as Data from "effect/Data";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";
15import { identity } from "effect/Function";
16import {
17 FetchHttpClient,
18 HttpClient,
19 HttpClientRequest,
20 HttpClientResponse,
21} from "effect/unstable/http";
22
23const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
24const RemoteAuthErrorBody = Schema.Struct({
25 error: Schema.optional(Schema.String),
26});
27const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody);
28
29const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
30 const url = new URL(httpBaseUrl);
31 url.pathname = pathname;
32 url.search = "";
33 url.hash = "";
34 return url.toString();
35};
36
37export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
38 "RemoteEnvironmentAuthFetchError",
39)<{
40 readonly message: string;
41 readonly cause: unknown;
42}> {}
43
44export class RemoteEnvironmentAuthResponseReadError extends Data.TaggedError(
45 "RemoteEnvironmentAuthResponseReadError",
46)<{
47 readonly message: string;
48 readonly cause: unknown;
49}> {}
50
51export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
52 "RemoteEnvironmentAuthInvalidJsonError",
53)<{
54 readonly message: string;
55 readonly cause: unknown;
56}> {}
57
58export class RemoteEnvironmentAuthHttpError extends Data.TaggedError(
59 "RemoteEnvironmentAuthHttpError",
60)<{
61 readonly message: string;
62 readonly status: number;
63}> {
64 constructor(message: string, status: number) {
65 super({ message, status });
66 }
67}
68
69export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError(
70 "RemoteEnvironmentAuthTimeoutError",
71)<{
72 readonly message: string;
73 readonly requestUrl: string;
74 readonly timeoutMs: number;
75}> {
76 constructor(requestUrl: string, timeoutMs: number) {
77 super({
78 message: `Remote auth endpoint ${requestUrl} timed out after ${timeoutMs}ms.`,
79 requestUrl,
80 timeoutMs,
81 });
82 }
83}
84
85export type RemoteEnvironmentAuthError =
86 | RemoteEnvironmentAuthFetchError
87 | RemoteEnvironmentAuthResponseReadError
88 | RemoteEnvironmentAuthInvalidJsonError
89 | RemoteEnvironmentAuthHttpError
90 | RemoteEnvironmentAuthTimeoutError;
91
92export const isRemoteEnvironmentAuthHttpError = (
93 error: unknown,
94): error is RemoteEnvironmentAuthHttpError => error instanceof RemoteEnvironmentAuthHttpError;
95
96const readRemoteAuthErrorMessage = (
97 response: HttpClientResponse.HttpClientResponse,
98 fallbackMessage: string,
99): Effect.Effect<string, RemoteEnvironmentAuthResponseReadError> =>
100 response.text.pipe(
101 Effect.mapError(
102 (cause) =>
103 new RemoteEnvironmentAuthResponseReadError({
104 message: "Remote auth endpoint returned an unreadable error response.",
105 cause,
106 }),
107 ),
108 Effect.map((text) => {
109 if (!text) {
110 return fallbackMessage;
111 }
112
113 const decoded = decodeRemoteAuthErrorBody(text);
114 if (Result.isSuccess(decoded) && decoded.success.error) {
115 return decoded.success.error;
116 }
117
118 return text;
119 }),
120 );
121
122const readRemoteJson = <T>(
123 response: HttpClientResponse.HttpClientResponse,
124 requestUrl: string,
125): Effect.Effect<T, RemoteEnvironmentAuthInvalidJsonError> =>
126 response.json.pipe(
127 Effect.mapError(
128 (cause) =>
129 new RemoteEnvironmentAuthInvalidJsonError({
130 message: `Remote auth endpoint returned invalid JSON from ${requestUrl}.`,
131 cause,
132 }),
133 ),
134 Effect.map((value) => value as T),
135 );
136
137export const remoteHttpClientLayer = (
138 fetchFn: typeof globalThis.fetch,
139): Layer.Layer<HttpClient.HttpClient> =>
140 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)));
141
142const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(function* <T>(input: {
143 readonly httpBaseUrl: string;
144 readonly pathname: string;
145 readonly method?: "GET" | "POST";
146 readonly bearerToken?: string;
147 readonly body?: unknown;
148 readonly timeoutMs?: number;
149}) {
150 const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname);
151 const method = input.method ?? "GET";
152 const timeoutMs = input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS;
153 const request = HttpClientRequest.make(method)(requestUrl).pipe(
154 input.bearerToken ? HttpClientRequest.bearerToken(input.bearerToken) : identity,
155 input.body !== undefined ? HttpClientRequest.bodyJsonUnsafe(input.body) : identity,
156 );
157
158 const client = yield* HttpClient.HttpClient;
159 const response = yield* client.execute(request).pipe(
160 Effect.mapError(
161 (cause) =>
162 new RemoteEnvironmentAuthFetchError({
163 message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`,
164 cause,
165 }),
166 ),
167 Effect.timeoutOption(Duration.millis(timeoutMs)),
168 Effect.flatMap(
169 Option.match({
170 onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)),
171 onSome: Effect.succeed,
172 }),
173 ),
174 );
175
176 if (response.status < 200 || response.status >= 300) {
177 return yield* readRemoteAuthErrorMessage(
178 response,
179 `Remote auth request failed (${response.status}).`,
180 ).pipe(
181 Effect.flatMap((message) =>
182 Effect.fail(new RemoteEnvironmentAuthHttpError(message, response.status)),
183 ),
184 );
185 }
186
187 return yield* readRemoteJson<T>(response, requestUrl);
188});
189
190export const bootstrapRemoteBearerSession = Effect.fn(
191 "clientRuntime.remote.bootstrapRemoteBearerSession",
192)(function* (input: {
193 readonly httpBaseUrl: string;
194 readonly credential: string;
195 readonly timeoutMs?: number;
196}) {
197 return yield* fetchRemoteJson<AuthBearerBootstrapResult>({
198 httpBaseUrl: input.httpBaseUrl,
199 pathname: "/api/auth/bootstrap/bearer",
200 method: "POST",
201 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
202 body: {
203 credential: input.credential,
204 },
205 });
206});
207
208export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")(
209 function* (input: {
210 readonly httpBaseUrl: string;
211 readonly bearerToken: string;
212 readonly timeoutMs?: number;
213 }) {
214 return yield* fetchRemoteJson<AuthSessionState>({
215 httpBaseUrl: input.httpBaseUrl,
216 pathname: "/api/auth/session",
217 bearerToken: input.bearerToken,
218 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
219 });
220 },
221);
222
223export const fetchRemoteEnvironmentDescriptor = Effect.fn(
224 "clientRuntime.remote.fetchRemoteEnvironmentDescriptor",
225)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) {
226 return yield* fetchRemoteJson<ExecutionEnvironmentDescriptor>({
227 httpBaseUrl: input.httpBaseUrl,
228 pathname: "/.well-known/t3/environment",
229 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
230 });
231});
232
233export const issueRemoteWebSocketToken = Effect.fn(
234 "clientRuntime.remote.issueRemoteWebSocketToken",
235)(function* (input: {
236 readonly httpBaseUrl: string;
237 readonly bearerToken: string;
238 readonly timeoutMs?: number;
239}) {
240 return yield* fetchRemoteJson<AuthWebSocketTokenResult>({
241 httpBaseUrl: input.httpBaseUrl,
242 pathname: "/api/auth/ws-token",
243 method: "POST",
244 bearerToken: input.bearerToken,
245 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
246 });
247});
248
249export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
250 "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl",
251)(function* (input: {
252 readonly wsBaseUrl: string;
253 readonly httpBaseUrl: string;
254 readonly bearerToken: string;
255 readonly timeoutMs?: number;
256}) {
257 const issued = yield* issueRemoteWebSocketToken({
258 httpBaseUrl: input.httpBaseUrl,
259 bearerToken: input.bearerToken,
260 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
261 });
262
263 const url = new URL(input.wsBaseUrl);
264 if (url.pathname === "" || url.pathname === "/") {
265 url.pathname = "/ws";
266 }
267 url.searchParams.set("wsToken", issued.token);
268 return url.toString();
269});
packages/client-runtime/src/scoped.ts+15-10
...@@ -1,19 +1,24 @@...@@ -1,19 +1,24 @@
1import type {1import {
2 EnvironmentId,2 EnvironmentId,
3 ProjectId,3 ProjectId,
4 ScopedProjectRef,
5 ScopedThreadRef,
6 ThreadId,4 ThreadId,
5 type EnvironmentId as EnvironmentIdType,
6 type ProjectId as ProjectIdType,
7 type ScopedProjectRef,
8 type ScopedThreadRef,
7} from "@t3tools/contracts";9} from "@t3tools/contracts";
810
9export function scopeProjectRef(11export function scopeProjectRef(
10 environmentId: EnvironmentId,12 environmentId: EnvironmentIdType,
11 projectId: ProjectId,13 projectId: ProjectIdType,
12): ScopedProjectRef {14): ScopedProjectRef {
13 return { environmentId, projectId };15 return { environmentId, projectId };
14}16}
1517
16export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId): ScopedThreadRef {18export function scopeThreadRef(
19 environmentId: EnvironmentIdType,
20 threadId: ThreadId,
21): ScopedThreadRef {
17 return { environmentId, threadId };22 return { environmentId, threadId };
18}23}
1924
...@@ -30,13 +35,13 @@ export function scopedThreadKey(ref: ScopedThreadRef): string {...@@ -30,13 +35,13 @@ export function scopedThreadKey(ref: ScopedThreadRef): string {
30 return scopedRefKey(ref);35 return scopedRefKey(ref);
31}36}
3237
33function parseScopedKey(key: string): { environmentId: EnvironmentId; localId: string } | null {38function parseScopedKey(key: string): { environmentId: EnvironmentIdType; localId: string } | null {
34 const separatorIndex = key.indexOf(":");39 const separatorIndex = key.indexOf(":");
35 if (separatorIndex <= 0 || separatorIndex >= key.length - 1) {40 if (separatorIndex <= 0 || separatorIndex >= key.length - 1) {
36 return null;41 return null;
37 }42 }
38 return {43 return {
39 environmentId: key.slice(0, separatorIndex) as EnvironmentId,44 environmentId: EnvironmentId.make(key.slice(0, separatorIndex)),
40 localId: key.slice(separatorIndex + 1),45 localId: key.slice(separatorIndex + 1),
41 };46 };
42}47}
...@@ -48,7 +53,7 @@ export function parseScopedProjectKey(key: string): ScopedProjectRef | null {...@@ -48,7 +53,7 @@ export function parseScopedProjectKey(key: string): ScopedProjectRef | null {
48 }53 }
49 return {54 return {
50 environmentId: parsed.environmentId,55 environmentId: parsed.environmentId,
51 projectId: parsed.localId as ProjectId,56 projectId: ProjectId.make(parsed.localId),
52 };57 };
53}58}
5459
...@@ -59,6 +64,6 @@ export function parseScopedThreadKey(key: string): ScopedThreadRef | null {...@@ -59,6 +64,6 @@ export function parseScopedThreadKey(key: string): ScopedThreadRef | null {
59 }64 }
60 return {65 return {
61 environmentId: parsed.environmentId,66 environmentId: parsed.environmentId,
62 threadId: parsed.localId as ThreadId,67 threadId: ThreadId.make(parsed.localId),
63 };68 };
64}69}
packages/client-runtime/src/shellSnapshotReducer.test.ts created+163
...@@ -0,0 +1,163 @@
1import { describe, expect, it } from "vitest";
2
3import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";
5
6import { applyShellStreamEvent } from "./shellSnapshotReducer.ts";
7
8const baseSnapshot: OrchestrationShellSnapshot = {
9 snapshotSequence: 0,
10 projects: [],
11 threads: [],
12 updatedAt: "2026-04-01T00:00:00.000Z",
13};
14
15const stubProject = {
16 id: ProjectId.make("project-1"),
17 title: "Test Project",
18 workspaceRoot: "/workspace/test",
19 repositoryIdentity: null,
20 defaultModelSelection: null,
21 scripts: [],
22 createdAt: "2026-04-01T00:00:00.000Z",
23 updatedAt: "2026-04-01T00:00:00.000Z",
24} as const;
25
26const stubThread = {
27 id: ThreadId.make("thread-1"),
28 projectId: ProjectId.make("project-1"),
29 title: "Test Thread",
30 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
31 runtimeMode: "full-access" as const,
32 interactionMode: "default" as const,
33 branch: null,
34 worktreePath: null,
35 latestTurn: null,
36 createdAt: "2026-04-01T00:00:00.000Z",
37 updatedAt: "2026-04-01T00:00:00.000Z",
38 archivedAt: null,
39 latestUserMessageAt: null,
40 hasPendingApprovals: false,
41 hasPendingUserInput: false,
42 hasActionableProposedPlan: false,
43 session: null,
44} as const;
45
46describe("applyShellStreamEvent", () => {
47 describe("project-upserted", () => {
48 it("adds a new project", () => {
49 const event: OrchestrationShellStreamEvent = {
50 kind: "project-upserted",
51 sequence: 1,
52 project: stubProject,
53 };
54
55 const next = applyShellStreamEvent(baseSnapshot, event);
56
57 expect(next.projects).toHaveLength(1);
58 expect(next.projects[0]?.id).toBe("project-1");
59 expect(next.snapshotSequence).toBe(1);
60 });
61
62 it("updates an existing project", () => {
63 const snapshotWithProject: OrchestrationShellSnapshot = {
64 ...baseSnapshot,
65 projects: [stubProject],
66 };
67
68 const updatedProject = { ...stubProject, title: "Updated Title" };
69 const event: OrchestrationShellStreamEvent = {
70 kind: "project-upserted",
71 sequence: 2,
72 project: updatedProject,
73 };
74
75 const next = applyShellStreamEvent(snapshotWithProject, event);
76
77 expect(next.projects).toHaveLength(1);
78 expect(next.projects[0]?.title).toBe("Updated Title");
79 expect(next.snapshotSequence).toBe(2);
80 });
81 });
82
83 describe("project-removed", () => {
84 it("removes a project by id", () => {
85 const snapshotWithProject: OrchestrationShellSnapshot = {
86 ...baseSnapshot,
87 projects: [stubProject],
88 };
89
90 const event: OrchestrationShellStreamEvent = {
91 kind: "project-removed",
92 sequence: 3,
93 projectId: ProjectId.make("project-1"),
94 };
95
96 const next = applyShellStreamEvent(snapshotWithProject, event);
97
98 expect(next.projects).toHaveLength(0);
99 expect(next.snapshotSequence).toBe(3);
100 });
101 });
102
103 describe("thread-upserted", () => {
104 it("adds a new thread", () => {
105 const event: OrchestrationShellStreamEvent = {
106 kind: "thread-upserted",
107 sequence: 4,
108 thread: stubThread,
109 };
110
111 const next = applyShellStreamEvent(baseSnapshot, event);
112
113 expect(next.threads).toHaveLength(1);
114 expect(next.threads[0]?.id).toBe("thread-1");
115 expect(next.snapshotSequence).toBe(4);
116 });
117
118 it("updates an existing thread", () => {
119 const snapshotWithThread: OrchestrationShellSnapshot = {
120 ...baseSnapshot,
121 threads: [stubThread],
122 };
123
124 const updatedThread = { ...stubThread, title: "Updated Thread" };
125 const event: OrchestrationShellStreamEvent = {
126 kind: "thread-upserted",
127 sequence: 5,
128 thread: updatedThread,
129 };
130
131 const next = applyShellStreamEvent(snapshotWithThread, event);
132
133 expect(next.threads).toHaveLength(1);
134 expect(next.threads[0]?.title).toBe("Updated Thread");
135 });
136 });
137
138 describe("thread-removed", () => {
139 it("removes a thread by id", () => {
140 const snapshotWithThread: OrchestrationShellSnapshot = {
141 ...baseSnapshot,
142 threads: [stubThread],
143 };
144
145 const event: OrchestrationShellStreamEvent = {
146 kind: "thread-removed",
147 sequence: 6,
148 threadId: ThreadId.make("thread-1"),
149 };
150
151 const next = applyShellStreamEvent(snapshotWithThread, event);
152
153 expect(next.threads).toHaveLength(0);
154 expect(next.snapshotSequence).toBe(6);
155 });
156 });
157
158 it("returns original snapshot for unrecognized event kinds", () => {
159 const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any;
160 const next = applyShellStreamEvent(baseSnapshot, unknownEvent);
161 expect(next).toBe(baseSnapshot);
162 });
163});
packages/client-runtime/src/shellSnapshotReducer.ts created+44
...@@ -0,0 +1,44 @@
1import * as Arr from "effect/Array";
2import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";
3
4/**
5 * Apply a single shell stream event to an existing snapshot, returning a new
6 * snapshot with the event's changes applied. This is a pure reducer that both
7 * web and mobile can use to keep their local shell snapshot in sync.
8 *
9 * Returns the original snapshot reference unchanged if the event is not
10 * recognized (forward-compatible).
11 */
12export function applyShellStreamEvent(
13 snapshot: OrchestrationShellSnapshot,
14 event: OrchestrationShellStreamEvent,
15): OrchestrationShellSnapshot {
16 switch (event.kind) {
17 case "project-upserted": {
18 const projects = snapshot.projects.some((p) => p.id === event.project.id)
19 ? Arr.map(snapshot.projects, (p) => (p.id === event.project.id ? event.project : p))
20 : Arr.append(snapshot.projects, event.project);
21 return { ...snapshot, projects, snapshotSequence: event.sequence };
22 }
23 case "project-removed":
24 return {
25 ...snapshot,
26 projects: Arr.filter(snapshot.projects, (p) => p.id !== event.projectId),
27 snapshotSequence: event.sequence,
28 };
29 case "thread-upserted": {
30 const threads = snapshot.threads.some((t) => t.id === event.thread.id)
31 ? Arr.map(snapshot.threads, (t) => (t.id === event.thread.id ? event.thread : t))
32 : Arr.append(snapshot.threads, event.thread);
33 return { ...snapshot, threads, snapshotSequence: event.sequence };
34 }
35 case "thread-removed":
36 return {
37 ...snapshot,
38 threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId),
39 snapshotSequence: event.sequence,
40 };
41 default:
42 return snapshot;
43 }
44}
packages/client-runtime/src/shellSnapshotState.test.ts created+128
...@@ -0,0 +1,128 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import { afterEach, describe, expect, it } from "vitest";
3
4import {
5 EnvironmentId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9 type OrchestrationShellSnapshot,
10} from "@t3tools/contracts";
11
12import { createShellSnapshotManager } from "./shellSnapshotState.ts";
13
14let atomRegistry = AtomRegistry.make();
15
16function resetAtomRegistry() {
17 atomRegistry.dispose();
18 atomRegistry = AtomRegistry.make();
19}
20
21const BASE_SNAPSHOT: OrchestrationShellSnapshot = {
22 snapshotSequence: 1,
23 updatedAt: "2026-04-01T00:00:00.000Z",
24 projects: [
25 {
26 id: ProjectId.make("project-1"),
27 title: "Project",
28 workspaceRoot: "/repo",
29 repositoryIdentity: null,
30 defaultModelSelection: null,
31 scripts: [],
32 createdAt: "2026-04-01T00:00:00.000Z",
33 updatedAt: "2026-04-01T00:00:00.000Z",
34 },
35 ],
36 threads: [
37 {
38 id: ThreadId.make("thread-1"),
39 projectId: ProjectId.make("project-1"),
40 title: "Thread",
41 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
42 runtimeMode: "full-access",
43 interactionMode: "default",
44 branch: null,
45 worktreePath: null,
46 latestTurn: null,
47 createdAt: "2026-04-01T00:00:00.000Z",
48 updatedAt: "2026-04-01T00:00:00.000Z",
49 archivedAt: null,
50 session: null,
51 latestUserMessageAt: null,
52 hasPendingApprovals: false,
53 hasPendingUserInput: false,
54 hasActionableProposedPlan: false,
55 },
56 ],
57};
58
59const TARGET = { environmentId: EnvironmentId.make("env-local") } as const;
60
61describe("createShellSnapshotManager", () => {
62 afterEach(() => {
63 resetAtomRegistry();
64 });
65
66 it("starts pending when marked pending", () => {
67 const manager = createShellSnapshotManager({
68 getRegistry: () => atomRegistry,
69 });
70
71 manager.markPending(TARGET);
72
73 expect(manager.getSnapshot(TARGET)).toEqual({
74 data: null,
75 error: null,
76 isPending: true,
77 });
78 });
79
80 it("stores snapshots", () => {
81 const manager = createShellSnapshotManager({
82 getRegistry: () => atomRegistry,
83 });
84
85 manager.syncSnapshot(TARGET, BASE_SNAPSHOT);
86
87 expect(manager.getSnapshot(TARGET)).toEqual({
88 data: BASE_SNAPSHOT,
89 error: null,
90 isPending: false,
91 });
92 });
93
94 it("applies incremental shell events", () => {
95 const manager = createShellSnapshotManager({
96 getRegistry: () => atomRegistry,
97 });
98 const existingThread = BASE_SNAPSHOT.threads[0]!;
99
100 manager.syncSnapshot(TARGET, BASE_SNAPSHOT);
101 manager.applyEvent(TARGET, {
102 kind: "thread-upserted",
103 sequence: 2,
104 thread: {
105 ...existingThread,
106 title: "Renamed thread",
107 },
108 });
109
110 expect(manager.getSnapshot(TARGET).data?.threads[0]?.title).toBe("Renamed thread");
111 expect(manager.getSnapshot(TARGET).data?.snapshotSequence).toBe(2);
112 });
113
114 it("invalidates per environment", () => {
115 const manager = createShellSnapshotManager({
116 getRegistry: () => atomRegistry,
117 });
118
119 manager.syncSnapshot(TARGET, BASE_SNAPSHOT);
120 manager.invalidate(TARGET);
121
122 expect(manager.getSnapshot(TARGET)).toEqual({
123 data: null,
124 error: null,
125 isPending: false,
126 });
127 });
128});
packages/client-runtime/src/shellSnapshotState.ts created+140
...@@ -0,0 +1,140 @@
1import type {
2 EnvironmentId,
3 OrchestrationShellSnapshot,
4 OrchestrationShellStreamEvent,
5} from "@t3tools/contracts";
6import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
7
8import { applyShellStreamEvent } from "./shellSnapshotReducer.ts";
9
10export interface ShellSnapshotState {
11 readonly data: OrchestrationShellSnapshot | null;
12 readonly error: string | null;
13 readonly isPending: boolean;
14}
15
16export interface ShellSnapshotTarget {
17 readonly environmentId: EnvironmentId | null;
18}
19
20export const EMPTY_SHELL_SNAPSHOT_STATE = Object.freeze<ShellSnapshotState>({
21 data: null,
22 error: null,
23 isPending: false,
24});
25
26const INITIAL_SHELL_SNAPSHOT_STATE = Object.freeze<ShellSnapshotState>({
27 data: null,
28 error: null,
29 isPending: true,
30});
31
32const knownShellSnapshotKeys = new Set<string>();
33
34export const shellSnapshotStateAtom = Atom.family((key: string) => {
35 knownShellSnapshotKeys.add(key);
36 return Atom.make(INITIAL_SHELL_SNAPSHOT_STATE).pipe(
37 Atom.keepAlive,
38 Atom.withLabel(`shell-snapshot:${key}`),
39 );
40});
41
42export const EMPTY_SHELL_SNAPSHOT_ATOM = Atom.make(EMPTY_SHELL_SNAPSHOT_STATE).pipe(
43 Atom.keepAlive,
44 Atom.withLabel("shell-snapshot:null"),
45);
46
47export function getShellSnapshotTargetKey(target: ShellSnapshotTarget): string | null {
48 return target.environmentId;
49}
50
51export interface ShellSnapshotManagerConfig {
52 readonly getRegistry: () => AtomRegistry.AtomRegistry;
53}
54
55export function createShellSnapshotManager(config: ShellSnapshotManagerConfig) {
56 function getSnapshot(target: ShellSnapshotTarget): ShellSnapshotState {
57 const targetKey = getShellSnapshotTargetKey(target);
58 if (targetKey === null) {
59 return EMPTY_SHELL_SNAPSHOT_STATE;
60 }
61
62 return config.getRegistry().get(shellSnapshotStateAtom(targetKey));
63 }
64
65 function setState(targetKey: string, nextState: ShellSnapshotState): void {
66 config.getRegistry().set(shellSnapshotStateAtom(targetKey), nextState);
67 }
68
69 function markPending(target: ShellSnapshotTarget): void {
70 const targetKey = getShellSnapshotTargetKey(target);
71 if (targetKey === null) {
72 return;
73 }
74
75 const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey));
76 setState(targetKey, {
77 data: current.data,
78 error: null,
79 isPending: true,
80 });
81 }
82
83 function syncSnapshot(target: ShellSnapshotTarget, snapshot: OrchestrationShellSnapshot): void {
84 const targetKey = getShellSnapshotTargetKey(target);
85 if (targetKey === null) {
86 return;
87 }
88
89 setState(targetKey, {
90 data: snapshot,
91 error: null,
92 isPending: false,
93 });
94 }
95
96 function applyEvent(target: ShellSnapshotTarget, event: OrchestrationShellStreamEvent): void {
97 const targetKey = getShellSnapshotTargetKey(target);
98 if (targetKey === null) {
99 return;
100 }
101
102 const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey));
103 if (current.data === null) {
104 return;
105 }
106
107 setState(targetKey, {
108 data: applyShellStreamEvent(current.data, event),
109 error: null,
110 isPending: false,
111 });
112 }
113
114 function invalidate(target?: ShellSnapshotTarget): void {
115 if (target) {
116 const targetKey = getShellSnapshotTargetKey(target);
117 if (targetKey !== null) {
118 setState(targetKey, EMPTY_SHELL_SNAPSHOT_STATE);
119 }
120 return;
121 }
122
123 for (const key of knownShellSnapshotKeys) {
124 setState(key, EMPTY_SHELL_SNAPSHOT_STATE);
125 }
126 }
127
128 function reset(): void {
129 invalidate();
130 }
131
132 return {
133 markPending,
134 syncSnapshot,
135 applyEvent,
136 getSnapshot,
137 invalidate,
138 reset,
139 };
140}
packages/client-runtime/src/shellTypes.ts created+38
...@@ -0,0 +1,38 @@
1import type {
2 EnvironmentId,
3 OrchestrationProjectShell,
4 OrchestrationShellSnapshot,
5 OrchestrationThreadShell,
6 ThreadId,
7} from "@t3tools/contracts";
8
9export interface EnvironmentScopedProjectShell extends OrchestrationProjectShell {
10 readonly environmentId: EnvironmentId;
11}
12
13export interface EnvironmentScopedThreadShell extends OrchestrationThreadShell {
14 readonly environmentId: EnvironmentId;
15}
16
17export function scopeProjectShell(
18 environmentId: EnvironmentId,
19 project: OrchestrationProjectShell,
20): EnvironmentScopedProjectShell {
21 return { ...project, environmentId };
22}
23
24export function scopeThreadShell(
25 environmentId: EnvironmentId,
26 thread: OrchestrationThreadShell,
27): EnvironmentScopedThreadShell {
28 return { ...thread, environmentId };
29}
30
31export function selectScopedThreadShell(
32 snapshot: OrchestrationShellSnapshot | null,
33 environmentId: EnvironmentId,
34 threadId: ThreadId,
35): EnvironmentScopedThreadShell | null {
36 const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null;
37 return thread ? scopeThreadShell(environmentId, thread) : null;
38}
packages/client-runtime/src/sourceControlDiscoveryState.test.ts+202
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { assert, beforeEach, it } from "vitest";1import { assert, beforeEach, it } from "vitest";
2import type { SourceControlDiscoveryResult } from "@t3tools/contracts";2import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
3import * as Option from "effect/Option";
3import { AtomRegistry } from "effect/unstable/reactivity";4import { AtomRegistry } from "effect/unstable/reactivity";
45
5import {6import {
...@@ -12,17 +13,53 @@ const EMPTY_RESULT: SourceControlDiscoveryResult = {...@@ -12,17 +13,53 @@ const EMPTY_RESULT: SourceControlDiscoveryResult = {
12 sourceControlProviders: [],13 sourceControlProviders: [],
13};14};
1415
16const GITHUB_RESULT: SourceControlDiscoveryResult = {
17 versionControlSystems: [
18 {
19 kind: "git",
20 label: "Git",
21 implemented: true,
22 status: "available",
23 version: Option.some("2.51.0"),
24 installHint: "Install Git.",
25 detail: Option.none(),
26 },
27 ],
28 sourceControlProviders: [
29 {
30 kind: "github",
31 label: "GitHub",
32 status: "available",
33 version: Option.some("2.85.0"),
34 installHint: "Install GitHub CLI.",
35 detail: Option.none(),
36 auth: {
37 status: "authenticated",
38 account: Option.some("octo"),
39 host: Option.some("github.com"),
40 detail: Option.none(),
41 },
42 },
43 ],
44};
45
15function unresolvedDiscovery() {46function unresolvedDiscovery() {
16 throw new Error("Discovery resolver was not initialized.");47 throw new Error("Discovery resolver was not initialized.");
17}48}
1849
19let registry = AtomRegistry.make();50let registry = AtomRegistry.make();
2051
52const noop = () => undefined;
53
21beforeEach(() => {54beforeEach(() => {
22 registry.dispose();55 registry.dispose();
23 registry = AtomRegistry.make();56 registry = AtomRegistry.make();
24});57});
2558
59function flushAsyncWork(): Promise<void> {
60 return Promise.resolve().then(() => undefined);
61}
62
26it("stores refreshed discovery data in an atom snapshot", async () => {63it("stores refreshed discovery data in an atom snapshot", async () => {
27 const manager = createSourceControlDiscoveryManager({64 const manager = createSourceControlDiscoveryManager({
28 getRegistry: () => registry,65 getRegistry: () => registry,
...@@ -105,3 +142,168 @@ it("keeps the previous snapshot when refresh fails", async () => {...@@ -105,3 +142,168 @@ it("keeps the previous snapshot when refresh fails", async () => {
105 isPending: false,142 isPending: false,
106 });143 });
107});144});
145
146it("invalidates a discovery target back to the initial snapshot", async () => {
147 const manager = createSourceControlDiscoveryManager({
148 getRegistry: () => registry,
149 getClient: () => ({
150 discoverSourceControl: async () => GITHUB_RESULT,
151 }),
152 });
153
154 await manager.refresh({ key: "primary" });
155 manager.invalidate({ key: "primary" });
156
157 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
158 data: null,
159 error: null,
160 isPending: true,
161 });
162});
163
164it("ignores an in-flight refresh after the target is invalidated", async () => {
165 let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery;
166 const manager = createSourceControlDiscoveryManager({
167 getRegistry: () => registry,
168 getClient: () => ({
169 discoverSourceControl: () =>
170 new Promise<SourceControlDiscoveryResult>((resolve) => {
171 resolveDiscovery = resolve;
172 }),
173 }),
174 });
175
176 const refresh = manager.refresh({ key: "primary" });
177 manager.invalidate({ key: "primary" });
178 resolveDiscovery(GITHUB_RESULT);
179
180 const result = await refresh;
181
182 assert.strictEqual(result, GITHUB_RESULT);
183 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
184 data: null,
185 error: null,
186 isPending: true,
187 });
188});
189
190it("watches a discovery target with ref-counted client-change subscriptions", async () => {
191 let listener: () => void = noop;
192 let subscribeCalls = 0;
193 let unsubscribeCalls = 0;
194 let discoveryCalls = 0;
195 const client = {
196 discoverSourceControl: async () => {
197 discoveryCalls += 1;
198 return EMPTY_RESULT;
199 },
200 };
201 const manager = createSourceControlDiscoveryManager({
202 getRegistry: () => registry,
203 getClient: () => client,
204 subscribeClientChanges: (nextListener) => {
205 subscribeCalls += 1;
206 listener = nextListener;
207 return () => {
208 unsubscribeCalls += 1;
209 };
210 },
211 });
212
213 const firstUnwatch = manager.watch({ key: "primary" });
214 const secondUnwatch = manager.watch({ key: "primary" });
215 await flushAsyncWork();
216
217 assert.strictEqual(subscribeCalls, 1);
218 assert.strictEqual(discoveryCalls, 1);
219 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
220 data: EMPTY_RESULT,
221 error: null,
222 isPending: false,
223 });
224
225 listener();
226 await flushAsyncWork();
227 assert.strictEqual(discoveryCalls, 1);
228
229 firstUnwatch();
230 assert.strictEqual(unsubscribeCalls, 0);
231
232 secondUnwatch();
233 assert.strictEqual(unsubscribeCalls, 1);
234});
235
236it("reuses fresh watched discovery results on remount", async () => {
237 let discoveryCalls = 0;
238 const client = {
239 discoverSourceControl: async () => {
240 discoveryCalls += 1;
241 return EMPTY_RESULT;
242 },
243 };
244 const manager = createSourceControlDiscoveryManager({
245 getRegistry: () => registry,
246 getClient: () => client,
247 staleTimeMs: 60_000,
248 });
249
250 const firstUnwatch = manager.watch({ key: "primary" });
251 await flushAsyncWork();
252 firstUnwatch();
253
254 const secondUnwatch = manager.watch({ key: "primary" });
255 await flushAsyncWork();
256 secondUnwatch();
257
258 assert.strictEqual(discoveryCalls, 1);
259});
260
261it("refreshes a watched discovery target when the resolved client is replaced", async () => {
262 let listener: () => void = noop;
263 let activeResult = EMPTY_RESULT;
264 let discoveryCalls = 0;
265 const firstClient = {
266 discoverSourceControl: async () => {
267 discoveryCalls += 1;
268 return activeResult;
269 },
270 };
271 const secondClient = {
272 discoverSourceControl: async () => {
273 discoveryCalls += 1;
274 return activeResult;
275 },
276 };
277 let activeClient = firstClient;
278 const manager = createSourceControlDiscoveryManager({
279 getRegistry: () => registry,
280 getClient: () => activeClient,
281 subscribeClientChanges: (nextListener) => {
282 listener = nextListener;
283 return () => undefined;
284 },
285 });
286
287 const unwatch = manager.watch({ key: "primary" });
288 await flushAsyncWork();
289
290 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
291 data: EMPTY_RESULT,
292 error: null,
293 isPending: false,
294 });
295
296 activeClient = secondClient;
297 activeResult = GITHUB_RESULT;
298 listener();
299 await flushAsyncWork();
300
301 assert.strictEqual(discoveryCalls, 2);
302 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
303 data: GITHUB_RESULT,
304 error: null,
305 isPending: false,
306 });
307
308 unwatch();
309});
packages/client-runtime/src/sourceControlDiscoveryState.ts+218-23
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { SourceControlDiscoveryResult } from "@t3tools/contracts";1import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";3import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
34
4/* --- Types ---------------------------------------------------------- */5/* --- Types ---------------------------------------------------------- */
...@@ -9,14 +10,19 @@ export interface SourceControlDiscoveryState {...@@ -9,14 +10,19 @@ export interface SourceControlDiscoveryState {
9 readonly isPending: boolean;10 readonly isPending: boolean;
10}11}
1112
12export interface SourceControlDiscoveryTarget {13export interface SourceControlDiscoveryTarget<TKey extends string = string> {
13 readonly key: string | null;14 readonly key: TKey | null;
14}15}
1516
16export interface SourceControlDiscoveryClient {17export interface SourceControlDiscoveryClient {
17 readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;18 readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
18}19}
1920
21interface WatchedEntry {
22 refCount: number;
23 teardown: () => void;
24}
25
20/* --- Constants ------------------------------------------------------ */26/* --- Constants ------------------------------------------------------ */
2127
22export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({28export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
...@@ -49,16 +55,16 @@ export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make(...@@ -49,16 +55,16 @@ export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make(
4955
50/* --- Helpers -------------------------------------------------------- */56/* --- Helpers -------------------------------------------------------- */
5157
52export function getSourceControlDiscoveryTargetKey(58export function getSourceControlDiscoveryTargetKey<TKey extends string>(
53 target: SourceControlDiscoveryTarget,59 target: SourceControlDiscoveryTarget<TKey>,
54): string | null {60): TKey | null {
55 const key = target.key?.trim();61 const key = target.key;
56 return key && key.length > 0 ? key : null;62 return key && key.length > 0 ? key : null;
57}63}
5864
59/* --- Refresh manager ------------------------------------------------ */65/* --- Refresh manager ------------------------------------------------ */
6066
61export interface SourceControlDiscoveryManagerConfig {67export interface SourceControlDiscoveryManagerConfig<TKey extends string = string> {
62 /**68 /**
63 * Get the atom registry used to read/write source-control discovery snapshots.69 * Get the atom registry used to read/write source-control discovery snapshots.
64 */70 */
...@@ -70,11 +76,61 @@ export interface SourceControlDiscoveryManagerConfig {...@@ -70,11 +76,61 @@ export interface SourceControlDiscoveryManagerConfig {
70 * lets mobile or future multi-environment clients provide separate discovery76 * lets mobile or future multi-environment clients provide separate discovery
71 * clients without changing the state primitive.77 * clients without changing the state primitive.
72 */78 */
73 readonly getClient: (key: string) => SourceControlDiscoveryClient | null;79 readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null;
80 /**
81 * Optional: subscribe to environment/client availability changes.
82 *
83 * When provided, `watch` refreshes as clients appear or are replaced
84 * instead of relying on React hooks to manually kick discovery.
85 */
86 readonly subscribeClientChanges?: (listener: () => void) => () => void;
87 readonly staleTimeMs?: number;
88 readonly idleTtlMs?: number;
74}89}
7590
76export function createSourceControlDiscoveryManager(config: SourceControlDiscoveryManagerConfig) {91const NOOP: () => void = () => undefined;
77 const refreshInFlight = new Map<string, Promise<SourceControlDiscoveryResult | null>>();92const DEFAULT_STALE_TIME_MS = 30_000;
93const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
94
95export function createSourceControlDiscoveryManager<TKey extends string = string>(
96 config: SourceControlDiscoveryManagerConfig<TKey>,
97) {
98 const refreshInFlight = new Map<
99 string,
100 {
101 readonly client: SourceControlDiscoveryClient;
102 readonly promise: Promise<SourceControlDiscoveryResult | null>;
103 }
104 >();
105 const refreshVersions = new Map<string, number>();
106 const watched = new Map<string, WatchedEntry>();
107 const refreshTargets = new Map<string, SourceControlDiscoveryTarget<TKey>>();
108 const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS;
109 const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
110
111 const watchedRefreshAtom = Atom.family((targetKey: string) =>
112 Atom.make(() =>
113 Effect.promise(() => {
114 const target = refreshTargets.get(targetKey);
115 return target ? refresh(target) : Promise.resolve(null);
116 }),
117 ).pipe(
118 Atom.swr({
119 staleTime: staleTimeMs,
120 revalidateOnMount: true,
121 }),
122 Atom.setIdleTTL(idleTtlMs),
123 Atom.withLabel(`source-control-discovery:watched-refresh:${targetKey}`),
124 ),
125 );
126
127 function getRefreshVersion(targetKey: string): number {
128 return refreshVersions.get(targetKey) ?? 0;
129 }
130
131 function bumpRefreshVersion(targetKey: string): void {
132 refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1);
133 }
78134
79 /* -- Atom helpers -------------------------------------------------- */135 /* -- Atom helpers -------------------------------------------------- */
80136
...@@ -135,18 +191,14 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove...@@ -135,18 +191,14 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove
135 * @param client Optional pre-resolved client, useful in tests.191 * @param client Optional pre-resolved client, useful in tests.
136 */192 */
137 function refresh(193 function refresh(
138 target: SourceControlDiscoveryTarget,194 target: SourceControlDiscoveryTarget<TKey>,
139 client?: SourceControlDiscoveryClient,195 client?: SourceControlDiscoveryClient,
140 ): Promise<SourceControlDiscoveryResult | null> {196 ): Promise<SourceControlDiscoveryResult | null> {
141 const targetKey = getSourceControlDiscoveryTargetKey(target);197 const targetKey = getSourceControlDiscoveryTargetKey(target);
142 if (targetKey === null) {198 if (targetKey === null) {
143 return Promise.resolve(null);199 return Promise.resolve(null);
144 }200 }
145201 refreshTargets.set(targetKey, target);
146 const existing = refreshInFlight.get(targetKey);
147 if (existing) {
148 return existing;
149 }
150202
151 const resolvedClient = client ?? config.getClient(targetKey);203 const resolvedClient = client ?? config.getClient(targetKey);
152 if (!resolvedClient) {204 if (!resolvedClient) {
...@@ -155,29 +207,72 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove...@@ -155,29 +207,72 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove
155 return Promise.resolve(getSnapshot(target).data);207 return Promise.resolve(getSnapshot(target).data);
156 }208 }
157209
210 const existing = refreshInFlight.get(targetKey);
211 if (existing) {
212 if (!client || existing.client === resolvedClient) {
213 return existing.promise;
214 }
215
216 return existing.promise.then(() => refresh(target, resolvedClient));
217 }
218
158 markPending(targetKey);219 markPending(targetKey);
220 const refreshVersion = getRefreshVersion(targetKey);
159 const promise = resolvedClient.discoverSourceControl().then(221 const promise = resolvedClient.discoverSourceControl().then(
160 (result) => {222 (result) => {
161 setData(targetKey, result);223 if (getRefreshVersion(targetKey) === refreshVersion) {
224 setData(targetKey, result);
225 }
162 return result;226 return result;
163 },227 },
164 (error: unknown) => {228 (error: unknown) => {
165 setError(targetKey, error);229 if (getRefreshVersion(targetKey) === refreshVersion) {
230 setError(targetKey, error);
231 }
166 return getSnapshot(target).data;232 return getSnapshot(target).data;
167 },233 },
168 );234 );
169 const tracked = promise.finally(() => refreshInFlight.delete(targetKey));235 let tracked: Promise<SourceControlDiscoveryResult | null>;
170 refreshInFlight.set(targetKey, tracked);236 tracked = promise.finally(() => {
237 if (refreshInFlight.get(targetKey)?.promise === tracked) {
238 refreshInFlight.delete(targetKey);
239 }
240 });
241 refreshInFlight.set(targetKey, {
242 client: resolvedClient,
243 promise: tracked,
244 });
171 return tracked;245 return tracked;
172 }246 }
173247
248 /**
249 * Reset discovery state for one target and ignore any currently in-flight
250 * refresh for that target. If no target is provided, every known target is
251 * invalidated.
252 */
253 function invalidate(target?: SourceControlDiscoveryTarget<TKey>): void {
254 if (!target) {
255 reset();
256 return;
257 }
258
259 const targetKey = getSourceControlDiscoveryTargetKey(target);
260 if (targetKey === null) {
261 return;
262 }
263
264 bumpRefreshVersion(targetKey);
265 refreshInFlight.delete(targetKey);
266 setState(targetKey, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE);
267 }
268
174 /**269 /**
175 * Read the current atom snapshot for `target`.270 * Read the current atom snapshot for `target`.
176 *271 *
177 * Invalid targets return the inert empty state rather than creating a new272 * Invalid targets return the inert empty state rather than creating a new
178 * family atom entry.273 * family atom entry.
179 */274 */
180 function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState {275 function getSnapshot(target: SourceControlDiscoveryTarget<TKey>): SourceControlDiscoveryState {
181 const targetKey = getSourceControlDiscoveryTargetKey(target);276 const targetKey = getSourceControlDiscoveryTargetKey(target);
182 if (targetKey === null) {277 if (targetKey === null) {
183 return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE;278 return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE;
...@@ -186,21 +281,121 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove...@@ -186,21 +281,121 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove
186 return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));281 return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
187 }282 }
188283
284 /**
285 * Keep discovery warm for `target`.
286 *
287 * Multiple callers sharing a target key are ref-counted. With
288 * `subscribeClientChanges`, the manager refreshes whenever a client first
289 * appears or is replaced after reconnect.
290 */
291 function watch(
292 target: SourceControlDiscoveryTarget<TKey>,
293 client?: SourceControlDiscoveryClient,
294 ): () => void {
295 const targetKey = getSourceControlDiscoveryTargetKey(target);
296 if (targetKey === null) {
297 return NOOP;
298 }
299 refreshTargets.set(targetKey, target);
300
301 const existing = watched.get(targetKey);
302 if (existing) {
303 existing.refCount += 1;
304 return () => unwatch(targetKey);
305 }
306
307 let teardown: () => void;
308
309 if (client) {
310 void refresh(target, client);
311 teardown = NOOP;
312 } else if (config.subscribeClientChanges) {
313 let currentClient: SourceControlDiscoveryClient | null = null;
314
315 const sync = () => {
316 const resolved = config.getClient(targetKey);
317 if (!resolved) {
318 currentClient = null;
319 markPending(targetKey);
320 return;
321 }
322
323 if (currentClient === resolved) {
324 return;
325 }
326
327 const isClientReplacement = currentClient !== null;
328 currentClient = resolved;
329 refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined);
330 };
331
332 const unsubChanges = config.subscribeClientChanges(sync);
333 sync();
334 teardown = unsubChanges;
335 } else {
336 if (!config.getClient(targetKey)) {
337 return NOOP;
338 }
339 refreshWatchedTarget(targetKey, target);
340 teardown = NOOP;
341 }
342
343 watched.set(targetKey, { refCount: 1, teardown });
344 return () => unwatch(targetKey);
345 }
346
347 function unwatch(targetKey: string): void {
348 const entry = watched.get(targetKey);
349 if (!entry) {
350 return;
351 }
352
353 entry.refCount -= 1;
354 if (entry.refCount > 0) {
355 return;
356 }
357
358 entry.teardown();
359 watched.delete(targetKey);
360 }
361
362 function refreshWatchedTarget(
363 targetKey: string,
364 target: SourceControlDiscoveryTarget<TKey>,
365 client?: SourceControlDiscoveryClient,
366 ): void {
367 refreshTargets.set(targetKey, target);
368 if (client) {
369 void refresh(target, client);
370 return;
371 }
372
373 config.getRegistry().get(watchedRefreshAtom(targetKey));
374 }
375
189 /**376 /**
190 * Clear in-flight refresh tracking and reset every known discovery atom.377 * Clear in-flight refresh tracking and reset every known discovery atom.
191 * Primarily used by tests and runtime teardown.378 * Primarily used by tests and runtime teardown.
192 */379 */
193 function reset(): void {380 function reset(): void {
381 const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]);
382 for (const entry of watched.values()) {
383 entry.teardown();
384 }
385 watched.clear();
386 refreshTargets.clear();
194 refreshInFlight.clear();387 refreshInFlight.clear();
195 for (const key of knownSourceControlDiscoveryKeys) {388 for (const key of keys) {
389 bumpRefreshVersion(key);
196 setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE);390 setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE);
197 }391 }
198 knownSourceControlDiscoveryKeys.clear();
199 }392 }
200393
201 return {394 return {
395 watch,
202 refresh,396 refresh,
203 getSnapshot,397 getSnapshot,
398 invalidate,
204 reset,399 reset,
205 };400 };
206}401}
packages/client-runtime/src/terminalSessionState.test.ts created+558
...@@ -0,0 +1,558 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import { afterEach, describe, expect, it } from "vitest";
3
4import {
5 EnvironmentId,
6 TerminalAttachStreamEvent,
7 TerminalMetadataStreamEvent,
8 TerminalSessionSnapshot,
9 ThreadId,
10} from "@t3tools/contracts";
11
12import {
13 createTerminalSessionManager,
14 getKnownTerminalSessionListFilter,
15 knownTerminalSessionsAtom,
16 runningTerminalIdsAtom,
17 terminalSessionStateAtom,
18 type KnownTerminalSessionTarget,
19} from "./terminalSessionState.ts";
20
21let atomRegistry = AtomRegistry.make();
22
23function resetAtomRegistry() {
24 atomRegistry.dispose();
25 atomRegistry = AtomRegistry.make();
26}
27
28const TARGET = {
29 environmentId: EnvironmentId.make("env-local"),
30 threadId: ThreadId.make("thread-1"),
31 terminalId: "term-1",
32} as const;
33
34const BASE_SNAPSHOT: TerminalSessionSnapshot = {
35 threadId: TARGET.threadId,
36 terminalId: TARGET.terminalId,
37 cwd: "/repo",
38 worktreePath: null,
39 status: "running",
40 pid: 123,
41 history: "hello",
42 exitCode: null,
43 exitSignal: null,
44 label: "Terminal 1",
45 updatedAt: "2026-04-01T00:00:00.000Z",
46};
47
48type TerminalSessionManager = ReturnType<typeof createTerminalSessionManager>;
49
50function applyAttachEvents(
51 manager: TerminalSessionManager,
52 target: KnownTerminalSessionTarget,
53 events: ReadonlyArray<TerminalAttachStreamEvent>,
54): void {
55 manager.attach({
56 environmentId: target.environmentId,
57 terminal: {
58 threadId: target.threadId,
59 terminalId: target.terminalId,
60 },
61 client: {
62 terminal: {
63 attach: (_input, listener) => {
64 events.forEach(listener);
65 return () => undefined;
66 },
67 },
68 },
69 })();
70}
71
72function applyMetadataEvents(
73 manager: TerminalSessionManager,
74 environmentId: EnvironmentId,
75 events: ReadonlyArray<TerminalMetadataStreamEvent>,
76): void {
77 manager.subscribeMetadata({
78 environmentId,
79 client: {
80 terminal: {
81 onMetadata: (listener) => {
82 events.forEach(listener);
83 return () => undefined;
84 },
85 },
86 },
87 })();
88}
89
90describe("createTerminalSessionManager", () => {
91 afterEach(() => {
92 resetAtomRegistry();
93 });
94
95 it("hydrates from started snapshots and appends output events", () => {
96 const manager = createTerminalSessionManager({
97 getRegistry: () => atomRegistry,
98 });
99
100 applyAttachEvents(manager, TARGET, [
101 {
102 type: "snapshot",
103 snapshot: BASE_SNAPSHOT,
104 },
105 {
106 type: "output",
107 threadId: TARGET.threadId,
108 terminalId: TARGET.terminalId,
109 data: " world",
110 },
111 ]);
112
113 expect(manager.getSnapshot(TARGET)).toMatchObject({
114 summary: null,
115 buffer: "hello world",
116 status: "running",
117 error: null,
118 updatedAt: BASE_SNAPSHOT.updatedAt,
119 });
120 });
121
122 it("caps retained output", () => {
123 const manager = createTerminalSessionManager({
124 getRegistry: () => atomRegistry,
125 maxBufferBytes: 5,
126 });
127
128 applyAttachEvents(manager, TARGET, [
129 {
130 type: "output",
131 threadId: TARGET.threadId,
132 terminalId: TARGET.terminalId,
133 data: "abcdef",
134 },
135 ]);
136
137 expect(manager.getSnapshot(TARGET).buffer).toBe("bcdef");
138 });
139
140 it("caps retained output by utf-8 byte length", () => {
141 const manager = createTerminalSessionManager({
142 getRegistry: () => atomRegistry,
143 maxBufferBytes: 4,
144 });
145
146 applyAttachEvents(manager, TARGET, [
147 {
148 type: "output",
149 threadId: TARGET.threadId,
150 terminalId: TARGET.terminalId,
151 data: "🙂🙂",
152 },
153 ]);
154
155 expect(manager.getSnapshot(TARGET).buffer).toBe("🙂");
156 });
157
158 it("invalidates one environment without clearing others", () => {
159 const manager = createTerminalSessionManager({
160 getRegistry: () => atomRegistry,
161 });
162 const otherTarget = {
163 environmentId: EnvironmentId.make("env-remote"),
164 threadId: ThreadId.make("thread-1"),
165 terminalId: "term-1",
166 } as const;
167
168 for (const target of [TARGET, otherTarget]) {
169 applyAttachEvents(manager, target, [
170 {
171 type: "output",
172 threadId: target.threadId,
173 terminalId: target.terminalId,
174 data: target.environmentId,
175 },
176 ]);
177 }
178
179 manager.invalidateEnvironment(TARGET.environmentId);
180
181 expect(manager.getSnapshot(TARGET).buffer).toBe("");
182 expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote");
183 });
184
185 it("lists known sessions for a thread ordered by terminal id (numeric-aware)", () => {
186 const manager = createTerminalSessionManager({
187 getRegistry: () => atomRegistry,
188 });
189
190 applyMetadataEvents(manager, TARGET.environmentId, [
191 {
192 type: "snapshot",
193 terminals: [
194 {
195 threadId: TARGET.threadId,
196 terminalId: "term-10",
197 cwd: "/repo",
198 worktreePath: null,
199 status: "running",
200 pid: 125,
201 exitCode: null,
202 exitSignal: null,
203 updatedAt: "2026-04-01T00:00:05.000Z",
204 hasRunningSubprocess: false,
205 label: "Terminal 10",
206 },
207 {
208 threadId: TARGET.threadId,
209 terminalId: TARGET.terminalId,
210 cwd: "/repo",
211 worktreePath: null,
212 status: "running",
213 pid: 123,
214 exitCode: null,
215 exitSignal: null,
216 updatedAt: "2026-04-01T00:00:00.000Z",
217 hasRunningSubprocess: false,
218 label: "Terminal 1",
219 },
220 {
221 threadId: TARGET.threadId,
222 terminalId: "term-2",
223 cwd: "/repo",
224 worktreePath: null,
225 status: "running",
226 pid: 124,
227 exitCode: null,
228 exitSignal: null,
229 updatedAt: "2026-04-01T00:00:02.000Z",
230 hasRunningSubprocess: false,
231 label: "Terminal 2",
232 },
233 ],
234 },
235 ]);
236
237 expect(
238 manager
239 .listSessions({
240 environmentId: TARGET.environmentId,
241 threadId: TARGET.threadId,
242 })
243 .map((session) => session.target.terminalId),
244 ).toEqual(["term-1", "term-2", "term-10"]);
245 });
246
247 it("drops known sessions when an environment is invalidated", () => {
248 const manager = createTerminalSessionManager({
249 getRegistry: () => atomRegistry,
250 });
251
252 applyAttachEvents(manager, TARGET, [
253 {
254 type: "output",
255 threadId: TARGET.threadId,
256 terminalId: TARGET.terminalId,
257 data: "hello",
258 },
259 ]);
260
261 manager.invalidateEnvironment(TARGET.environmentId);
262
263 expect(
264 manager.listSessions({
265 environmentId: TARGET.environmentId,
266 threadId: TARGET.threadId,
267 }),
268 ).toEqual([]);
269 });
270
271 it("removes closed sessions from the known-session index while keeping local closed state", () => {
272 const manager = createTerminalSessionManager({
273 getRegistry: () => atomRegistry,
274 });
275
276 applyMetadataEvents(manager, TARGET.environmentId, [
277 {
278 type: "upsert",
279 terminal: {
280 threadId: TARGET.threadId,
281 terminalId: TARGET.terminalId,
282 cwd: "/repo",
283 worktreePath: null,
284 status: "running",
285 pid: 123,
286 exitCode: null,
287 exitSignal: null,
288 updatedAt: BASE_SNAPSHOT.updatedAt,
289 hasRunningSubprocess: false,
290 label: "Terminal 1",
291 },
292 },
293 ]);
294 applyAttachEvents(manager, TARGET, [
295 {
296 type: "snapshot",
297 snapshot: BASE_SNAPSHOT,
298 },
299 {
300 type: "closed",
301 threadId: TARGET.threadId,
302 terminalId: TARGET.terminalId,
303 },
304 ]);
305 applyMetadataEvents(manager, TARGET.environmentId, [
306 {
307 type: "remove",
308 threadId: TARGET.threadId,
309 terminalId: TARGET.terminalId,
310 },
311 ]);
312
313 expect(
314 manager.listSessions({
315 environmentId: TARGET.environmentId,
316 threadId: TARGET.threadId,
317 }),
318 ).toEqual([]);
319 expect(manager.getSnapshot(TARGET)).toMatchObject({
320 buffer: "hello",
321 status: "closed",
322 summary: null,
323 updatedAt: BASE_SNAPSHOT.updatedAt,
324 });
325 });
326
327 it("clears locally retained closed state on reset", () => {
328 const manager = createTerminalSessionManager({
329 getRegistry: () => atomRegistry,
330 });
331
332 applyAttachEvents(manager, TARGET, [
333 {
334 type: "snapshot",
335 snapshot: BASE_SNAPSHOT,
336 },
337 {
338 type: "closed",
339 threadId: TARGET.threadId,
340 terminalId: TARGET.terminalId,
341 },
342 ]);
343
344 manager.reset();
345
346 expect(manager.getSnapshot(TARGET)).toEqual({
347 summary: null,
348 buffer: "",
349 status: "closed",
350 error: null,
351 hasRunningSubprocess: false,
352 updatedAt: null,
353 version: 0,
354 });
355 });
356
357 it("syncs snapshots returned from open calls immediately", () => {
358 const manager = createTerminalSessionManager({
359 getRegistry: () => atomRegistry,
360 });
361
362 applyAttachEvents(manager, TARGET, [
363 {
364 type: "snapshot",
365 snapshot: {
366 ...BASE_SNAPSHOT,
367 history: "prompt$ ",
368 updatedAt: "2026-04-01T00:00:03.000Z",
369 },
370 },
371 ]);
372
373 expect(manager.getSnapshot(TARGET)).toMatchObject({
374 buffer: "prompt$ ",
375 status: "running",
376 updatedAt: "2026-04-01T00:00:03.000Z",
377 });
378 });
379
380 it("syncs authoritative metadata snapshots and removes missing environment terminals", () => {
381 const manager = createTerminalSessionManager({
382 getRegistry: () => atomRegistry,
383 });
384
385 applyAttachEvents(manager, TARGET, [
386 {
387 type: "snapshot",
388 snapshot: BASE_SNAPSHOT,
389 },
390 ]);
391 applyAttachEvents(
392 manager,
393 {
394 environmentId: TARGET.environmentId,
395 threadId: TARGET.threadId,
396 terminalId: "term-2",
397 },
398 [
399 {
400 type: "snapshot",
401 snapshot: {
402 ...BASE_SNAPSHOT,
403 terminalId: "term-2",
404 label: "Terminal 2",
405 updatedAt: "2026-04-01T00:00:02.000Z",
406 },
407 },
408 ],
409 );
410
411 applyMetadataEvents(manager, TARGET.environmentId, [
412 {
413 type: "snapshot",
414 terminals: [
415 {
416 threadId: TARGET.threadId,
417 terminalId: "term-2",
418 cwd: "/repo",
419 worktreePath: null,
420 status: "running",
421 pid: 123,
422 exitCode: null,
423 exitSignal: null,
424 updatedAt: "2026-04-01T00:00:05.000Z",
425 hasRunningSubprocess: true,
426 label: "Terminal 2",
427 },
428 ],
429 },
430 ]);
431
432 expect(
433 manager.listSessions({
434 environmentId: TARGET.environmentId,
435 threadId: TARGET.threadId,
436 }),
437 ).toMatchObject([
438 {
439 target: {
440 environmentId: TARGET.environmentId,
441 threadId: TARGET.threadId,
442 terminalId: "term-2",
443 },
444 state: {
445 summary: {
446 terminalId: "term-2",
447 cwd: "/repo",
448 },
449 hasRunningSubprocess: true,
450 },
451 },
452 ]);
453 });
454
455 it("updates listed session metadata when existing session activity changes", () => {
456 const manager = createTerminalSessionManager({
457 getRegistry: () => atomRegistry,
458 });
459
460 applyMetadataEvents(manager, TARGET.environmentId, [
461 {
462 type: "upsert",
463 terminal: {
464 threadId: TARGET.threadId,
465 terminalId: TARGET.terminalId,
466 cwd: "/repo",
467 worktreePath: null,
468 status: "running",
469 pid: 123,
470 exitCode: null,
471 exitSignal: null,
472 updatedAt: BASE_SNAPSHOT.updatedAt,
473 hasRunningSubprocess: false,
474 label: "Terminal 1",
475 },
476 },
477 ]);
478
479 applyMetadataEvents(manager, TARGET.environmentId, [
480 {
481 type: "upsert",
482 terminal: {
483 threadId: TARGET.threadId,
484 terminalId: TARGET.terminalId,
485 cwd: "/repo",
486 worktreePath: null,
487 status: "running",
488 pid: 123,
489 exitCode: null,
490 exitSignal: null,
491 updatedAt: "2026-04-01T00:00:05.000Z",
492 hasRunningSubprocess: true,
493 label: "Terminal 1",
494 },
495 },
496 ]);
497
498 expect(
499 manager.listSessions({ environmentId: TARGET.environmentId, threadId: TARGET.threadId }),
500 ).toMatchObject([
501 {
502 state: {
503 hasRunningSubprocess: true,
504 },
505 },
506 ]);
507 });
508
509 it("derives session atoms from structurally equal target objects", () => {
510 const manager = createTerminalSessionManager({
511 getRegistry: () => atomRegistry,
512 });
513
514 applyMetadataEvents(manager, TARGET.environmentId, [
515 {
516 type: "upsert",
517 terminal: {
518 threadId: TARGET.threadId,
519 terminalId: TARGET.terminalId,
520 cwd: "/repo",
521 worktreePath: null,
522 status: "running",
523 pid: 123,
524 exitCode: null,
525 exitSignal: null,
526 updatedAt: BASE_SNAPSHOT.updatedAt,
527 hasRunningSubprocess: true,
528 label: "Terminal 1",
529 },
530 },
531 ]);
532 applyAttachEvents(manager, TARGET, [
533 {
534 type: "snapshot",
535 snapshot: BASE_SNAPSHOT,
536 },
537 ]);
538
539 const equalTarget = { ...TARGET };
540 const filter = getKnownTerminalSessionListFilter({
541 environmentId: TARGET.environmentId,
542 threadId: TARGET.threadId,
543 });
544 expect(filter).not.toBeNull();
545 if (filter === null) {
546 return;
547 }
548
549 expect(atomRegistry.get(terminalSessionStateAtom(equalTarget))).toMatchObject({
550 buffer: BASE_SNAPSHOT.history,
551 hasRunningSubprocess: true,
552 });
553 expect(
554 atomRegistry.get(knownTerminalSessionsAtom({ ...filter })).map((session) => session.target),
555 ).toEqual([TARGET]);
556 expect(atomRegistry.get(runningTerminalIdsAtom({ ...filter }))).toEqual([TARGET.terminalId]);
557 });
558});
packages/client-runtime/src/terminalSessionState.ts created+605
...@@ -0,0 +1,605 @@
1import type {
2 TerminalAttachStreamEvent,
3 TerminalMetadataStreamEvent,
4 TerminalSessionSnapshot,
5 TerminalSummary,
6 EnvironmentId,
7} from "@t3tools/contracts";
8import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts";
9import * as Arr from "effect/Array";
10import { pipe } from "effect/Function";
11import * as Order from "effect/Order";
12import * as Result from "effect/Result";
13import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
14
15export interface TerminalSessionState {
16 readonly summary: TerminalSummary | null;
17 readonly buffer: string;
18 readonly status: TerminalSessionSnapshot["status"] | "closed";
19 readonly error: string | null;
20 readonly hasRunningSubprocess: boolean;
21 readonly updatedAt: string | null;
22 readonly version: number;
23}
24
25export interface TerminalBufferState {
26 readonly buffer: string;
27 readonly status: TerminalSessionSnapshot["status"] | "closed";
28 readonly error: string | null;
29 readonly updatedAt: string | null;
30 readonly version: number;
31}
32
33export interface TerminalSessionTarget {
34 readonly environmentId: EnvironmentId | null;
35 readonly threadId: ThreadId | null;
36 readonly terminalId: string | null;
37}
38
39export interface KnownTerminalSessionTarget {
40 readonly environmentId: EnvironmentId;
41 readonly threadId: ThreadId;
42 readonly terminalId: string;
43}
44
45export interface KnownTerminalSession {
46 readonly target: KnownTerminalSessionTarget;
47 readonly state: TerminalSessionState;
48}
49
50export interface KnownTerminalMetadata {
51 readonly target: KnownTerminalSessionTarget;
52 readonly summary: TerminalSummary;
53}
54
55export interface TerminalSessionListFilter {
56 readonly environmentId: EnvironmentId | null;
57 readonly threadId?: ThreadId | null;
58 readonly terminalId?: string | null;
59}
60
61export interface KnownTerminalSessionListFilter {
62 readonly environmentId: EnvironmentId;
63 readonly threadId: ThreadId | null;
64 readonly terminalId: string | null;
65}
66
67export interface TerminalSessionManagerConfig {
68 readonly getRegistry: () => AtomRegistry.AtomRegistry;
69 readonly maxBufferBytes?: number;
70}
71
72export interface TerminalMetadataClient {
73 readonly terminal: {
74 readonly onMetadata: (
75 listener: (event: TerminalMetadataStreamEvent) => void,
76 options?: { readonly onResubscribe?: () => void },
77 ) => () => void;
78 };
79}
80
81export interface TerminalAttachClient {
82 readonly terminal: {
83 readonly attach: (
84 input: TerminalAttachInput,
85 listener: (event: TerminalAttachStreamEvent) => void,
86 options?: { readonly onResubscribe?: () => void },
87 ) => () => void;
88 };
89}
90
91export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
92 buffer: "",
93 status: "closed",
94 error: null,
95 updatedAt: null,
96 version: 0,
97});
98
99export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({
100 summary: null,
101 buffer: "",
102 status: "closed",
103 error: null,
104 hasRunningSubprocess: false,
105 updatedAt: null,
106 version: 0,
107});
108
109const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<Array<KnownTerminalSession>>([]);
110const EMPTY_TERMINAL_ID_LIST = Object.freeze<Array<string>>([]);
111const DEFAULT_MAX_BUFFER_BYTES = 512 * 1024;
112const knownTerminalMetadataEnvironmentIds = new Set<EnvironmentId>();
113const knownTerminalBufferTargets = new Map<string, KnownTerminalSessionTarget>();
114const textEncoder = new TextEncoder();
115const textDecoder = new TextDecoder();
116const terminalIdOrder = Order.make<string>(
117 (left, right) => left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1,
118);
119const knownTerminalSessionOrder = Order.mapInput(
120 terminalIdOrder,
121 (session: KnownTerminalSession) => session.target.terminalId,
122);
123
124export const terminalSessionMetadataAtom = Atom.family((environmentId: EnvironmentId) => {
125 knownTerminalMetadataEnvironmentIds.add(environmentId);
126 return Atom.make<Record<string, KnownTerminalMetadata>>({}).pipe(
127 Atom.keepAlive,
128 Atom.withLabel(`terminal-session:metadata:${environmentId}`),
129 );
130});
131
132export const terminalSessionBufferAtom = Atom.family((target: KnownTerminalSessionTarget) => {
133 const key = keyFromKnownTarget(target);
134 knownTerminalBufferTargets.set(key, target);
135 return Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe(
136 Atom.keepAlive,
137 Atom.withLabel(`terminal-session:buffer:${key}`),
138 );
139});
140
141export const EMPTY_TERMINAL_BUFFER_ATOM = Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe(
142 Atom.keepAlive,
143 Atom.withLabel("terminal-session:buffer:null"),
144);
145
146export const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe(
147 Atom.keepAlive,
148 Atom.withLabel("terminal-session:state:null"),
149);
150
151export const EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make(EMPTY_KNOWN_TERMINAL_SESSIONS).pipe(
152 Atom.keepAlive,
153 Atom.withLabel("terminal-session:known:null"),
154);
155
156export const EMPTY_TERMINAL_ID_LIST_ATOM = Atom.make(EMPTY_TERMINAL_ID_LIST).pipe(
157 Atom.keepAlive,
158 Atom.withLabel("terminal-session:running-terminal-ids:null"),
159);
160
161export function getKnownTerminalSessionTarget(
162 target: TerminalSessionTarget,
163): KnownTerminalSessionTarget | null {
164 if (target.environmentId === null || target.threadId === null || target.terminalId === null) {
165 return null;
166 }
167
168 return {
169 environmentId: target.environmentId,
170 threadId: target.threadId,
171 terminalId: target.terminalId,
172 };
173}
174
175export function getKnownTerminalSessionListFilter(
176 filter: TerminalSessionListFilter,
177): KnownTerminalSessionListFilter | null {
178 if (filter.environmentId === null) {
179 return null;
180 }
181
182 return {
183 environmentId: filter.environmentId,
184 threadId: filter.threadId ?? null,
185 terminalId: filter.terminalId ?? null,
186 };
187}
188
189function knownTargetFromSummary(
190 environmentId: EnvironmentId,
191 summary: TerminalSummary,
192): KnownTerminalSessionTarget {
193 return {
194 environmentId,
195 threadId: ThreadId.make(summary.threadId),
196 terminalId: summary.terminalId,
197 };
198}
199
200function keyFromKnownTarget(target: KnownTerminalSessionTarget): string {
201 return `${target.environmentId}:${target.threadId}:${target.terminalId}`;
202}
203
204function trimBufferToBytes(buffer: string, maxBufferBytes: number): string {
205 if (maxBufferBytes <= 0) {
206 return "";
207 }
208
209 const encoded = textEncoder.encode(buffer);
210 if (encoded.byteLength <= maxBufferBytes) {
211 return buffer;
212 }
213
214 let start = encoded.byteLength - maxBufferBytes;
215 while (start < encoded.length) {
216 const byte = encoded[start];
217 if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) {
218 break;
219 }
220 start += 1;
221 }
222
223 return textDecoder.decode(encoded.subarray(start));
224}
225
226function bufferFromSnapshot(
227 snapshot: TerminalSessionSnapshot,
228 maxBufferBytes: number,
229): TerminalBufferState {
230 return {
231 buffer: trimBufferToBytes(snapshot.history, maxBufferBytes),
232 status: snapshot.status,
233 error: null,
234 updatedAt: snapshot.updatedAt,
235 version: 1,
236 };
237}
238
239function latestTimestamp(left: string | null, right: string | null): string | null {
240 if (left === null) return right;
241 if (right === null) return left;
242 return Date.parse(left) >= Date.parse(right) ? left : right;
243}
244
245function combineSessionState(
246 summary: TerminalSummary | null,
247 buffer: TerminalBufferState,
248): TerminalSessionState {
249 return {
250 summary,
251 buffer: buffer.buffer,
252 status: summary?.status ?? buffer.status,
253 error: buffer.error,
254 hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
255 updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt),
256 version: buffer.version,
257 };
258}
259
260function listKnownSessionsFromMetadata(
261 metadata: Record<string, KnownTerminalMetadata>,
262 getBuffer: (target: KnownTerminalSessionTarget) => TerminalBufferState,
263 filter?: Partial<KnownTerminalSessionTarget>,
264): ReadonlyArray<KnownTerminalSession> {
265 return pipe(
266 Object.values(metadata),
267 Arr.filterMap(({ target, summary }) => {
268 if (filter?.environmentId && target.environmentId !== filter.environmentId) {
269 return Result.failVoid;
270 }
271 if (filter?.threadId && target.threadId !== filter.threadId) {
272 return Result.failVoid;
273 }
274 if (filter?.terminalId && target.terminalId !== filter.terminalId) {
275 return Result.failVoid;
276 }
277 return Result.succeed({
278 target,
279 state: combineSessionState(summary, getBuffer(target)),
280 });
281 }),
282 Arr.sort(knownTerminalSessionOrder),
283 );
284}
285
286export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) =>
287 Atom.make((get) => {
288 const targetKey = keyFromKnownTarget(target);
289 return combineSessionState(
290 get(terminalSessionMetadataAtom(target.environmentId))[targetKey]?.summary ?? null,
291 get(terminalSessionBufferAtom(target)),
292 );
293 }).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:state:${keyFromKnownTarget(target)}`)),
294);
295
296export const knownTerminalSessionsAtom = Atom.family((filter: KnownTerminalSessionListFilter) =>
297 Atom.make((get) =>
298 listKnownSessionsFromMetadata(
299 get(terminalSessionMetadataAtom(filter.environmentId)),
300 (target) => get(terminalSessionBufferAtom(target)),
301 {
302 environmentId: filter.environmentId,
303 ...(filter.threadId !== null ? { threadId: filter.threadId } : {}),
304 ...(filter.terminalId !== null ? { terminalId: filter.terminalId } : {}),
305 },
306 ),
307 ).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:known:${JSON.stringify(filter)}`)),
308);
309
310export const runningTerminalIdsAtom = Atom.family((filter: KnownTerminalSessionListFilter) =>
311 Atom.make((get) => {
312 return pipe(
313 Object.values(get(terminalSessionMetadataAtom(filter.environmentId))),
314 Arr.filterMap((entry) =>
315 entry.target.environmentId === filter.environmentId &&
316 (filter.threadId === null || entry.target.threadId === filter.threadId) &&
317 (filter.terminalId === null || entry.target.terminalId === filter.terminalId) &&
318 entry.summary.hasRunningSubprocess
319 ? Result.succeed(entry.target.terminalId)
320 : Result.failVoid,
321 ),
322 Arr.sort(Order.String),
323 );
324 }).pipe(
325 Atom.keepAlive,
326 Atom.withLabel(`terminal-session:running-terminal-ids:${JSON.stringify(filter)}`),
327 ),
328);
329
330export function createTerminalSessionManager(config: TerminalSessionManagerConfig) {
331 const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
332
333 function getMetadata(environmentId: EnvironmentId): Record<string, KnownTerminalMetadata> {
334 return config.getRegistry().get(terminalSessionMetadataAtom(environmentId));
335 }
336
337 function setMetadata(
338 environmentId: EnvironmentId,
339 next: Record<string, KnownTerminalMetadata>,
340 ): void {
341 config.getRegistry().set(terminalSessionMetadataAtom(environmentId), next);
342 }
343
344 function getBuffer(target: KnownTerminalSessionTarget): TerminalBufferState {
345 return config.getRegistry().get(terminalSessionBufferAtom(target));
346 }
347
348 function setBuffer(target: KnownTerminalSessionTarget, next: TerminalBufferState): void {
349 config.getRegistry().set(terminalSessionBufferAtom(target), next);
350 }
351
352 function getSnapshot(target: TerminalSessionTarget): TerminalSessionState {
353 const knownTarget = getKnownTerminalSessionTarget(target);
354 if (knownTarget === null) {
355 return EMPTY_TERMINAL_SESSION_STATE;
356 }
357
358 return combineSessionState(
359 getMetadata(knownTarget.environmentId)[keyFromKnownTarget(knownTarget)]?.summary ?? null,
360 getBuffer(knownTarget),
361 );
362 }
363
364 function syncSnapshot(
365 target: Pick<TerminalSessionTarget, "environmentId">,
366 snapshot: TerminalSessionSnapshot,
367 ): void {
368 const knownTarget = getKnownTerminalSessionTarget({
369 environmentId: target.environmentId,
370 threadId: ThreadId.make(snapshot.threadId),
371 terminalId: snapshot.terminalId,
372 });
373 if (knownTarget === null) {
374 return;
375 }
376
377 setBuffer(knownTarget, bufferFromSnapshot(snapshot, maxBufferBytes));
378 }
379
380 function applyMetadataEvent(
381 target: Pick<TerminalSessionTarget, "environmentId">,
382 event: TerminalMetadataStreamEvent,
383 ): void {
384 const environmentId = target.environmentId;
385 if (environmentId === null) {
386 return;
387 }
388
389 if (event.type === "snapshot") {
390 const retainedKeys = new Set<string>();
391 const next = { ...getMetadata(environmentId) };
392
393 for (const terminal of event.terminals) {
394 const knownTarget = knownTargetFromSummary(environmentId, terminal);
395 const targetKey = keyFromKnownTarget(knownTarget);
396 retainedKeys.add(targetKey);
397 next[targetKey] = {
398 target: knownTarget,
399 summary: terminal,
400 };
401 }
402
403 for (const key of Object.keys(next)) {
404 if (!retainedKeys.has(key)) {
405 delete next[key];
406 }
407 }
408
409 setMetadata(environmentId, next);
410 return;
411 }
412
413 if (event.type === "upsert") {
414 const knownTarget = knownTargetFromSummary(environmentId, event.terminal);
415 const targetKey = keyFromKnownTarget(knownTarget);
416 setMetadata(environmentId, {
417 ...getMetadata(environmentId),
418 [targetKey]: {
419 target: knownTarget,
420 summary: event.terminal,
421 },
422 });
423 return;
424 }
425
426 const knownTarget = getKnownTerminalSessionTarget({
427 environmentId,
428 threadId: ThreadId.make(event.threadId),
429 terminalId: event.terminalId,
430 });
431 if (knownTarget === null) {
432 return;
433 }
434
435 const next = { ...getMetadata(environmentId) };
436 delete next[keyFromKnownTarget(knownTarget)];
437 setMetadata(environmentId, next);
438 }
439
440 function applyAttachEvent(
441 target: Pick<TerminalSessionTarget, "environmentId">,
442 event: TerminalAttachStreamEvent,
443 ): void {
444 if (event.type === "snapshot") {
445 syncSnapshot(target, event.snapshot);
446 return;
447 }
448
449 const knownTarget = getKnownTerminalSessionTarget({
450 environmentId: target.environmentId,
451 threadId: ThreadId.make(event.threadId),
452 terminalId: event.terminalId,
453 });
454 if (knownTarget === null) {
455 return;
456 }
457
458 const current = getBuffer(knownTarget);
459 switch (event.type) {
460 case "restarted":
461 setBuffer(knownTarget, bufferFromSnapshot(event.snapshot, maxBufferBytes));
462 return;
463 case "output":
464 setBuffer(knownTarget, {
465 ...current,
466 buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes),
467 status: current.status === "closed" ? "running" : current.status,
468 error: null,
469 version: current.version + 1,
470 });
471 return;
472 case "cleared":
473 setBuffer(knownTarget, {
474 ...current,
475 buffer: "",
476 error: null,
477 version: current.version + 1,
478 });
479 return;
480 case "exited":
481 setBuffer(knownTarget, {
482 ...current,
483 status: "exited",
484 error: null,
485 version: current.version + 1,
486 });
487 return;
488 case "closed":
489 setBuffer(knownTarget, {
490 ...current,
491 status: "closed",
492 error: null,
493 version: current.version + 1,
494 });
495 return;
496 case "error":
497 setBuffer(knownTarget, {
498 ...current,
499 status: "error",
500 error: event.message,
501 version: current.version + 1,
502 });
503 return;
504 case "activity":
505 return;
506 }
507 }
508
509 function invalidate(target?: TerminalSessionTarget): void {
510 if (target) {
511 const knownTarget = getKnownTerminalSessionTarget(target);
512 if (knownTarget !== null) {
513 const targetKey = keyFromKnownTarget(knownTarget);
514 const next = { ...getMetadata(knownTarget.environmentId) };
515 delete next[targetKey];
516 setMetadata(knownTarget.environmentId, next);
517 setBuffer(knownTarget, EMPTY_TERMINAL_BUFFER_STATE);
518 }
519 return;
520 }
521
522 for (const environmentId of knownTerminalMetadataEnvironmentIds) {
523 setMetadata(environmentId, {});
524 }
525 knownTerminalMetadataEnvironmentIds.clear();
526 for (const target of knownTerminalBufferTargets.values()) {
527 setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE);
528 }
529 knownTerminalBufferTargets.clear();
530 }
531
532 function invalidateEnvironment(environmentId: EnvironmentId): void {
533 setMetadata(environmentId, {});
534 knownTerminalMetadataEnvironmentIds.delete(environmentId);
535
536 const prefix = `${environmentId}:`;
537 for (const [key, target] of knownTerminalBufferTargets) {
538 if (key.startsWith(prefix)) {
539 setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE);
540 }
541 }
542 }
543
544 function reset(): void {
545 invalidate();
546 }
547
548 function listSessions(
549 filter?: Partial<KnownTerminalSessionTarget>,
550 ): ReadonlyArray<KnownTerminalSession> {
551 if (filter?.environmentId) {
552 return listKnownSessionsFromMetadata(getMetadata(filter.environmentId), getBuffer, filter);
553 }
554
555 return pipe(
556 knownTerminalMetadataEnvironmentIds,
557 Arr.fromIterable,
558 Arr.flatMap((environmentId) =>
559 listKnownSessionsFromMetadata(getMetadata(environmentId), getBuffer, filter),
560 ),
561 );
562 }
563
564 function subscribeMetadata(input: {
565 readonly environmentId: EnvironmentId;
566 readonly client: TerminalMetadataClient;
567 readonly options?: { readonly onResubscribe?: () => void };
568 }): () => void {
569 return input.client.terminal.onMetadata(
570 (event) => applyMetadataEvent({ environmentId: input.environmentId }, event),
571 input.options,
572 );
573 }
574
575 function attach(input: {
576 readonly environmentId: EnvironmentId;
577 readonly client: TerminalAttachClient;
578 readonly terminal: TerminalAttachInput;
579 readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void;
580 readonly onEvent?: (event: TerminalAttachStreamEvent) => void;
581 readonly options?: { readonly onResubscribe?: () => void };
582 }): () => void {
583 return input.client.terminal.attach(
584 input.terminal,
585 (event) => {
586 applyAttachEvent({ environmentId: input.environmentId }, event);
587 input.onEvent?.(event);
588 if (event.type === "snapshot") {
589 input.onSnapshot?.(event.snapshot);
590 }
591 },
592 input.options,
593 );
594 }
595
596 return {
597 attach,
598 getSnapshot,
599 invalidate,
600 invalidateEnvironment,
601 listSessions,
602 subscribeMetadata,
603 reset,
604 };
605}
packages/client-runtime/src/threadDetailReducer.test.ts created+568
...@@ -0,0 +1,568 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 CheckpointRef,
5 EventId,
6 MessageId,
7 ProjectId,
8 ProviderInstanceId,
9 ThreadId,
10 TurnId,
11} from "@t3tools/contracts";
12import type { OrchestrationThread } from "@t3tools/contracts";
13
14import { applyThreadDetailEvent } from "./threadDetailReducer.ts";
15
16const baseEventFields = {
17 eventId: EventId.make("event-1"),
18 commandId: null,
19 causationEventId: null,
20 correlationId: null,
21 metadata: {},
22} as const;
23
24const baseThread: OrchestrationThread = {
25 id: ThreadId.make("thread-1"),
26 projectId: ProjectId.make("project-1"),
27 title: "Test Thread",
28 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
29 runtimeMode: "full-access",
30 interactionMode: "default",
31 branch: null,
32 worktreePath: null,
33 latestTurn: null,
34 createdAt: "2026-04-01T00:00:00.000Z",
35 updatedAt: "2026-04-01T00:00:00.000Z",
36 archivedAt: null,
37 deletedAt: null,
38 messages: [],
39 proposedPlans: [],
40 activities: [],
41 checkpoints: [],
42 session: null,
43};
44
45describe("applyThreadDetailEvent", () => {
46 describe("project events", () => {
47 it("returns unchanged for project.created", () => {
48 const result = applyThreadDetailEvent(baseThread, {
49 ...baseEventFields,
50 sequence: 1,
51 occurredAt: "2026-04-01T01:00:00.000Z",
52 aggregateKind: "project",
53 aggregateId: ProjectId.make("project-1"),
54 type: "project.created",
55 payload: {
56 projectId: ProjectId.make("project-1"),
57 title: "T3 Code",
58 workspaceRoot: "/repo",
59 repositoryIdentity: null,
60 defaultModelSelection: null,
61 scripts: [],
62 createdAt: "2026-04-01T01:00:00.000Z",
63 updatedAt: "2026-04-01T01:00:00.000Z",
64 deletedAt: null,
65 },
66 } as any);
67 expect(result.kind).toBe("unchanged");
68 });
69 });
70
71 describe("thread.created", () => {
72 it("creates a fresh thread", () => {
73 const result = applyThreadDetailEvent(baseThread, {
74 ...baseEventFields,
75 sequence: 1,
76 occurredAt: "2026-04-01T01:00:00.000Z",
77 aggregateKind: "thread",
78 aggregateId: ThreadId.make("thread-2"),
79 type: "thread.created",
80 payload: {
81 threadId: ThreadId.make("thread-2"),
82 projectId: ProjectId.make("project-1"),
83 title: "New Thread",
84 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
85 runtimeMode: "full-access",
86 interactionMode: "default",
87 branch: "main",
88 worktreePath: null,
89 createdAt: "2026-04-01T01:00:00.000Z",
90 updatedAt: "2026-04-01T01:00:00.000Z",
91 },
92 });
93
94 expect(result.kind).toBe("updated");
95 if (result.kind === "updated") {
96 expect(result.thread.id).toBe("thread-2");
97 expect(result.thread.title).toBe("New Thread");
98 expect(result.thread.branch).toBe("main");
99 expect(result.thread.messages).toEqual([]);
100 expect(result.thread.session).toBeNull();
101 }
102 });
103 });
104
105 describe("thread.deleted", () => {
106 it("returns deleted signal", () => {
107 const result = applyThreadDetailEvent(baseThread, {
108 ...baseEventFields,
109 sequence: 2,
110 occurredAt: "2026-04-01T02:00:00.000Z",
111 aggregateKind: "thread",
112 aggregateId: ThreadId.make("thread-1"),
113 type: "thread.deleted",
114 payload: {
115 threadId: ThreadId.make("thread-1"),
116 deletedAt: "2026-04-01T02:00:00.000Z",
117 },
118 });
119 expect(result.kind).toBe("deleted");
120 });
121 });
122
123 describe("thread.archived / thread.unarchived", () => {
124 it("sets archivedAt", () => {
125 const result = applyThreadDetailEvent(baseThread, {
126 ...baseEventFields,
127 sequence: 3,
128 occurredAt: "2026-04-01T03:00:00.000Z",
129 aggregateKind: "thread",
130 aggregateId: ThreadId.make("thread-1"),
131 type: "thread.archived",
132 payload: {
133 threadId: ThreadId.make("thread-1"),
134 archivedAt: "2026-04-01T03:00:00.000Z",
135 updatedAt: "2026-04-01T03:00:00.000Z",
136 },
137 });
138
139 expect(result.kind).toBe("updated");
140 if (result.kind === "updated") {
141 expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z");
142 }
143 });
144
145 it("clears archivedAt", () => {
146 const archivedThread = { ...baseThread, archivedAt: "2026-04-01T03:00:00.000Z" };
147 const result = applyThreadDetailEvent(archivedThread, {
148 ...baseEventFields,
149 sequence: 4,
150 occurredAt: "2026-04-01T04:00:00.000Z",
151 aggregateKind: "thread",
152 aggregateId: ThreadId.make("thread-1"),
153 type: "thread.unarchived",
154 payload: {
155 threadId: ThreadId.make("thread-1"),
156 updatedAt: "2026-04-01T04:00:00.000Z",
157 },
158 });
159
160 expect(result.kind).toBe("updated");
161 if (result.kind === "updated") {
162 expect(result.thread.archivedAt).toBeNull();
163 }
164 });
165 });
166
167 describe("thread.meta-updated", () => {
168 it("patches title and branch", () => {
169 const result = applyThreadDetailEvent(baseThread, {
170 ...baseEventFields,
171 sequence: 5,
172 occurredAt: "2026-04-01T05:00:00.000Z",
173 aggregateKind: "thread",
174 aggregateId: ThreadId.make("thread-1"),
175 type: "thread.meta-updated",
176 payload: {
177 threadId: ThreadId.make("thread-1"),
178 title: "Updated Title",
179 branch: "feature/demo",
180 updatedAt: "2026-04-01T05:00:00.000Z",
181 },
182 });
183
184 expect(result.kind).toBe("updated");
185 if (result.kind === "updated") {
186 expect(result.thread.title).toBe("Updated Title");
187 expect(result.thread.branch).toBe("feature/demo");
188 // Model selection should be unchanged since it wasn't in the payload
189 expect(result.thread.modelSelection).toEqual(baseThread.modelSelection);
190 }
191 });
192 });
193
194 describe("thread.message-sent", () => {
195 it("appends a new message", () => {
196 const result = applyThreadDetailEvent(baseThread, {
197 ...baseEventFields,
198 sequence: 6,
199 occurredAt: "2026-04-01T06:00:00.000Z",
200 aggregateKind: "thread",
201 aggregateId: ThreadId.make("thread-1"),
202 type: "thread.message-sent",
203 payload: {
204 threadId: ThreadId.make("thread-1"),
205 messageId: MessageId.make("msg-1"),
206 role: "user",
207 text: "Hello, world!",
208 turnId: null,
209 streaming: false,
210 createdAt: "2026-04-01T06:00:00.000Z",
211 updatedAt: "2026-04-01T06:00:00.000Z",
212 },
213 });
214
215 expect(result.kind).toBe("updated");
216 if (result.kind === "updated") {
217 expect(result.thread.messages).toHaveLength(1);
218 expect(result.thread.messages[0]?.text).toBe("Hello, world!");
219 }
220 });
221
222 it("appends text for streaming messages", () => {
223 const threadWithMessage: OrchestrationThread = {
224 ...baseThread,
225 messages: [
226 {
227 id: MessageId.make("msg-2"),
228 role: "assistant",
229 text: "Hello",
230 turnId: TurnId.make("turn-1"),
231 streaming: true,
232 createdAt: "2026-04-01T06:00:00.000Z",
233 updatedAt: "2026-04-01T06:00:00.000Z",
234 },
235 ],
236 };
237
238 const result = applyThreadDetailEvent(threadWithMessage, {
239 ...baseEventFields,
240 sequence: 7,
241 occurredAt: "2026-04-01T06:01:00.000Z",
242 aggregateKind: "thread",
243 aggregateId: ThreadId.make("thread-1"),
244 type: "thread.message-sent",
245 payload: {
246 threadId: ThreadId.make("thread-1"),
247 messageId: MessageId.make("msg-2"),
248 role: "assistant",
249 text: ", world!",
250 turnId: TurnId.make("turn-1"),
251 streaming: true,
252 createdAt: "2026-04-01T06:00:00.000Z",
253 updatedAt: "2026-04-01T06:01:00.000Z",
254 },
255 });
256
257 expect(result.kind).toBe("updated");
258 if (result.kind === "updated") {
259 expect(result.thread.messages).toHaveLength(1);
260 expect(result.thread.messages[0]?.text).toBe("Hello, world!");
261 }
262 });
263
264 it("updates latestTurn for assistant messages with a turn", () => {
265 const result = applyThreadDetailEvent(baseThread, {
266 ...baseEventFields,
267 sequence: 8,
268 occurredAt: "2026-04-01T07:00:00.000Z",
269 aggregateKind: "thread",
270 aggregateId: ThreadId.make("thread-1"),
271 type: "thread.message-sent",
272 payload: {
273 threadId: ThreadId.make("thread-1"),
274 messageId: MessageId.make("msg-3"),
275 role: "assistant",
276 text: "Done.",
277 turnId: TurnId.make("turn-1"),
278 streaming: false,
279 createdAt: "2026-04-01T07:00:00.000Z",
280 updatedAt: "2026-04-01T07:00:00.000Z",
281 },
282 });
283
284 expect(result.kind).toBe("updated");
285 if (result.kind === "updated") {
286 expect(result.thread.latestTurn?.turnId).toBe("turn-1");
287 expect(result.thread.latestTurn?.state).toBe("completed");
288 expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3");
289 }
290 });
291 });
292
293 describe("thread.session-set", () => {
294 it("updates session and latestTurn for a running session", () => {
295 const result = applyThreadDetailEvent(baseThread, {
296 ...baseEventFields,
297 sequence: 9,
298 occurredAt: "2026-04-01T08:00:00.000Z",
299 aggregateKind: "thread",
300 aggregateId: ThreadId.make("thread-1"),
301 type: "thread.session-set",
302 payload: {
303 threadId: ThreadId.make("thread-1"),
304 session: {
305 threadId: ThreadId.make("thread-1"),
306 status: "running",
307 providerName: "codex",
308 runtimeMode: "full-access",
309 activeTurnId: TurnId.make("turn-1"),
310 lastError: null,
311 updatedAt: "2026-04-01T08:00:00.000Z",
312 },
313 },
314 });
315
316 expect(result.kind).toBe("updated");
317 if (result.kind === "updated") {
318 expect(result.thread.session?.status).toBe("running");
319 expect(result.thread.latestTurn?.turnId).toBe("turn-1");
320 expect(result.thread.latestTurn?.state).toBe("running");
321 }
322 });
323 });
324
325 describe("thread.session-stop-requested", () => {
326 it("marks session as stopped", () => {
327 const threadWithSession: OrchestrationThread = {
328 ...baseThread,
329 session: {
330 threadId: ThreadId.make("thread-1"),
331 status: "running",
332 providerName: "codex",
333 runtimeMode: "full-access",
334 activeTurnId: TurnId.make("turn-1"),
335 lastError: null,
336 updatedAt: "2026-04-01T08:00:00.000Z",
337 },
338 };
339
340 const result = applyThreadDetailEvent(threadWithSession, {
341 ...baseEventFields,
342 sequence: 10,
343 occurredAt: "2026-04-01T09:00:00.000Z",
344 aggregateKind: "thread",
345 aggregateId: ThreadId.make("thread-1"),
346 type: "thread.session-stop-requested",
347 payload: {
348 threadId: ThreadId.make("thread-1"),
349 createdAt: "2026-04-01T09:00:00.000Z",
350 },
351 });
352
353 expect(result.kind).toBe("updated");
354 if (result.kind === "updated") {
355 expect(result.thread.session?.status).toBe("stopped");
356 expect(result.thread.session?.activeTurnId).toBeNull();
357 }
358 });
359
360 it("returns unchanged when no session exists", () => {
361 const result = applyThreadDetailEvent(baseThread, {
362 ...baseEventFields,
363 sequence: 10,
364 occurredAt: "2026-04-01T09:00:00.000Z",
365 aggregateKind: "thread",
366 aggregateId: ThreadId.make("thread-1"),
367 type: "thread.session-stop-requested",
368 payload: {
369 threadId: ThreadId.make("thread-1"),
370 createdAt: "2026-04-01T09:00:00.000Z",
371 },
372 });
373 expect(result.kind).toBe("unchanged");
374 });
375 });
376
377 describe("thread.proposed-plan-upserted", () => {
378 it("adds a proposed plan", () => {
379 const result = applyThreadDetailEvent(baseThread, {
380 ...baseEventFields,
381 sequence: 11,
382 occurredAt: "2026-04-01T10:00:00.000Z",
383 aggregateKind: "thread",
384 aggregateId: ThreadId.make("thread-1"),
385 type: "thread.proposed-plan-upserted",
386 payload: {
387 threadId: ThreadId.make("thread-1"),
388 proposedPlan: {
389 id: "plan-1",
390 turnId: TurnId.make("turn-1"),
391 planMarkdown: "## Plan\n- Do stuff",
392 implementedAt: null,
393 implementationThreadId: null,
394 createdAt: "2026-04-01T10:00:00.000Z",
395 updatedAt: "2026-04-01T10:00:00.000Z",
396 },
397 },
398 });
399
400 expect(result.kind).toBe("updated");
401 if (result.kind === "updated") {
402 expect(result.thread.proposedPlans).toHaveLength(1);
403 expect(result.thread.proposedPlans[0]?.id).toBe("plan-1");
404 }
405 });
406 });
407
408 describe("thread.activity-appended", () => {
409 it("adds an activity", () => {
410 const result = applyThreadDetailEvent(baseThread, {
411 ...baseEventFields,
412 sequence: 12,
413 occurredAt: "2026-04-01T11:00:00.000Z",
414 aggregateKind: "thread",
415 aggregateId: ThreadId.make("thread-1"),
416 type: "thread.activity-appended",
417 payload: {
418 threadId: ThreadId.make("thread-1"),
419 activity: {
420 id: EventId.make("activity-1"),
421 tone: "tool",
422 kind: "file-edit",
423 summary: "Edited src/index.ts",
424 payload: {},
425 turnId: TurnId.make("turn-1"),
426 createdAt: "2026-04-01T11:00:00.000Z",
427 },
428 },
429 });
430
431 expect(result.kind).toBe("updated");
432 if (result.kind === "updated") {
433 expect(result.thread.activities).toHaveLength(1);
434 expect(result.thread.activities[0]?.kind).toBe("file-edit");
435 }
436 });
437 });
438
439 describe("thread.turn-diff-completed", () => {
440 it("adds a checkpoint and updates latestTurn", () => {
441 const result = applyThreadDetailEvent(baseThread, {
442 ...baseEventFields,
443 sequence: 13,
444 occurredAt: "2026-04-01T12:00:00.000Z",
445 aggregateKind: "thread",
446 aggregateId: ThreadId.make("thread-1"),
447 type: "thread.turn-diff-completed",
448 payload: {
449 threadId: ThreadId.make("thread-1"),
450 turnId: TurnId.make("turn-1"),
451 checkpointTurnCount: 1,
452 checkpointRef: CheckpointRef.make("ref-1"),
453 status: "ready",
454 files: [],
455 assistantMessageId: MessageId.make("msg-3"),
456 completedAt: "2026-04-01T12:00:00.000Z",
457 },
458 });
459
460 expect(result.kind).toBe("updated");
461 if (result.kind === "updated") {
462 expect(result.thread.checkpoints).toHaveLength(1);
463 expect(result.thread.latestTurn?.turnId).toBe("turn-1");
464 expect(result.thread.latestTurn?.state).toBe("completed");
465 }
466 });
467 });
468
469 describe("thread.reverted", () => {
470 it("filters entities to retained turns", () => {
471 const threadWithData: OrchestrationThread = {
472 ...baseThread,
473 messages: [
474 {
475 id: MessageId.make("msg-1"),
476 role: "user",
477 text: "First",
478 turnId: null,
479 streaming: false,
480 createdAt: "2026-04-01T01:00:00.000Z",
481 updatedAt: "2026-04-01T01:00:00.000Z",
482 },
483 {
484 id: MessageId.make("msg-2"),
485 role: "assistant",
486 text: "Response 1",
487 turnId: TurnId.make("turn-1"),
488 streaming: false,
489 createdAt: "2026-04-01T02:00:00.000Z",
490 updatedAt: "2026-04-01T02:00:00.000Z",
491 },
492 {
493 id: MessageId.make("msg-3"),
494 role: "assistant",
495 text: "Response 2",
496 turnId: TurnId.make("turn-2"),
497 streaming: false,
498 createdAt: "2026-04-01T03:00:00.000Z",
499 updatedAt: "2026-04-01T03:00:00.000Z",
500 },
501 ],
502 checkpoints: [
503 {
504 turnId: TurnId.make("turn-1"),
505 checkpointTurnCount: 1,
506 checkpointRef: CheckpointRef.make("ref-1"),
507 status: "ready",
508 files: [],
509 assistantMessageId: MessageId.make("msg-2"),
510 completedAt: "2026-04-01T02:00:00.000Z",
511 },
512 {
513 turnId: TurnId.make("turn-2"),
514 checkpointTurnCount: 2,
515 checkpointRef: CheckpointRef.make("ref-2"),
516 status: "ready",
517 files: [],
518 assistantMessageId: MessageId.make("msg-3"),
519 completedAt: "2026-04-01T03:00:00.000Z",
520 },
521 ],
522 };
523
524 const result = applyThreadDetailEvent(threadWithData, {
525 ...baseEventFields,
526 sequence: 14,
527 occurredAt: "2026-04-01T04:00:00.000Z",
528 aggregateKind: "thread",
529 aggregateId: ThreadId.make("thread-1"),
530 type: "thread.reverted",
531 payload: {
532 threadId: ThreadId.make("thread-1"),
533 turnCount: 1,
534 },
535 });
536
537 expect(result.kind).toBe("updated");
538 if (result.kind === "updated") {
539 // turn-2 checkpoint is filtered out (turnCount 2 > revert target 1)
540 expect(result.thread.checkpoints).toHaveLength(1);
541 expect(result.thread.checkpoints[0]?.turnId).toBe("turn-1");
542 // msg-3 (turn-2) is filtered, msg-1 (no turn) and msg-2 (turn-1) remain
543 expect(result.thread.messages).toHaveLength(2);
544 expect(result.thread.latestTurn?.turnId).toBe("turn-1");
545 }
546 });
547 });
548
549 describe("no-op events", () => {
550 it("returns unchanged for approval-response-requested", () => {
551 const result = applyThreadDetailEvent(baseThread, {
552 ...baseEventFields,
553 sequence: 15,
554 occurredAt: "2026-04-01T13:00:00.000Z",
555 aggregateKind: "thread",
556 aggregateId: ThreadId.make("thread-1"),
557 type: "thread.approval-response-requested",
558 payload: {
559 threadId: ThreadId.make("thread-1"),
560 requestId: "req-1",
561 decision: "approve",
562 createdAt: "2026-04-01T13:00:00.000Z",
563 },
564 } as any);
565 expect(result.kind).toBe("unchanged");
566 });
567 });
568});
packages/client-runtime/src/threadDetailReducer.ts created+523
...@@ -0,0 +1,523 @@
1import { pipe } from "effect/Function";
2import * as Arr from "effect/Array";
3import * as O from "effect/Order";
4import type {
5 MessageId,
6 OrchestrationCheckpointSummary,
7 OrchestrationEvent,
8 OrchestrationLatestTurn,
9 OrchestrationMessage,
10 OrchestrationThread,
11 OrchestrationThreadActivity,
12 TurnId,
13} from "@t3tools/contracts";
14
15/**
16 * Retention limits for collections within a thread.
17 * These prevent unbounded growth of in-memory thread state.
18 */
19export interface ThreadDetailRetentionLimits {
20 readonly maxMessages: number;
21 readonly maxProposedPlans: number;
22 readonly maxCheckpoints: number;
23 readonly maxActivities: number;
24}
25
26export const DEFAULT_THREAD_DETAIL_LIMITS: ThreadDetailRetentionLimits = {
27 maxMessages: 512,
28 maxProposedPlans: 64,
29 maxCheckpoints: 256,
30 maxActivities: 128,
31};
32
33export type ThreadDetailReducerResult =
34 | { readonly kind: "updated"; readonly thread: OrchestrationThread }
35 | { readonly kind: "deleted" }
36 | { readonly kind: "unchanged" };
37
38const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(
39 O.mapInput(O.String, (p) => p.createdAt),
40 O.mapInput(O.String, (p) => p.id),
41);
42
43const checkpointOrder = O.mapInput(
44 O.Number,
45 (cp: OrchestrationThread["checkpoints"][number]) =>
46 cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER,
47);
48
49const activityOrder = O.combineAll<OrchestrationThreadActivity>([
50 O.mapInput(O.Number, (a) => a.sequence ?? Number.MAX_SAFE_INTEGER),
51 O.mapInput(O.String, (a) => a.createdAt),
52 O.mapInput(O.String, (a) => a.id),
53]);
54
55/**
56 * Apply a single orchestration event to an `OrchestrationThread`, returning
57 * the updated thread, a deletion signal, or an "unchanged" marker when the
58 * event doesn't affect this thread.
59 *
60 * This is a pure reducer operating on contract types. UI-specific mapping
61 * (e.g. resolving attachment preview URLs, normalising model slugs, adding
62 * scoped fields like `environmentId`) is the caller's responsibility.
63 */
64export function applyThreadDetailEvent(
65 thread: OrchestrationThread,
66 event: OrchestrationEvent,
67 limits: ThreadDetailRetentionLimits = DEFAULT_THREAD_DETAIL_LIMITS,
68): ThreadDetailReducerResult {
69 switch (event.type) {
70 // ── Project events (irrelevant to thread detail) ────────────────
71 case "project.created":
72 case "project.meta-updated":
73 case "project.deleted":
74 return { kind: "unchanged" };
75
76 // ── Thread lifecycle ────────────────────────────────────────────
77 case "thread.created":
78 return {
79 kind: "updated",
80 thread: {
81 id: event.payload.threadId,
82 projectId: event.payload.projectId,
83 title: event.payload.title,
84 modelSelection: event.payload.modelSelection,
85 runtimeMode: event.payload.runtimeMode,
86 interactionMode: event.payload.interactionMode,
87 branch: event.payload.branch,
88 worktreePath: event.payload.worktreePath,
89 latestTurn: null,
90 createdAt: event.payload.createdAt,
91 updatedAt: event.payload.updatedAt,
92 archivedAt: null,
93 deletedAt: null,
94 messages: [],
95 proposedPlans: [],
96 activities: [],
97 checkpoints: [],
98 session: null,
99 },
100 };
101
102 case "thread.deleted":
103 return { kind: "deleted" };
104
105 case "thread.archived":
106 return {
107 kind: "updated",
108 thread: {
109 ...thread,
110 archivedAt: event.payload.archivedAt,
111 updatedAt: event.payload.updatedAt,
112 },
113 };
114
115 case "thread.unarchived":
116 return {
117 kind: "updated",
118 thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt },
119 };
120
121 // ── Thread metadata ─────────────────────────────────────────────
122 case "thread.meta-updated":
123 return {
124 kind: "updated",
125 thread: {
126 ...thread,
127 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
128 ...(event.payload.modelSelection !== undefined
129 ? { modelSelection: event.payload.modelSelection }
130 : {}),
131 ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
132 ...(event.payload.worktreePath !== undefined
133 ? { worktreePath: event.payload.worktreePath }
134 : {}),
135 updatedAt: event.payload.updatedAt,
136 },
137 };
138
139 case "thread.runtime-mode-set":
140 return {
141 kind: "updated",
142 thread: {
143 ...thread,
144 runtimeMode: event.payload.runtimeMode,
145 updatedAt: event.payload.updatedAt,
146 },
147 };
148
149 case "thread.interaction-mode-set":
150 return {
151 kind: "updated",
152 thread: {
153 ...thread,
154 interactionMode: event.payload.interactionMode,
155 updatedAt: event.payload.updatedAt,
156 },
157 };
158
159 // ── Turn lifecycle ──────────────────────────────────────────────
160 case "thread.turn-start-requested":
161 return {
162 kind: "updated",
163 thread: {
164 ...thread,
165 ...(event.payload.modelSelection !== undefined
166 ? { modelSelection: event.payload.modelSelection }
167 : {}),
168 runtimeMode: event.payload.runtimeMode,
169 interactionMode: event.payload.interactionMode,
170 updatedAt: event.occurredAt,
171 },
172 };
173
174 case "thread.turn-interrupt-requested": {
175 if (event.payload.turnId === undefined) {
176 return { kind: "unchanged" };
177 }
178 const latestTurn = thread.latestTurn;
179 if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) {
180 return { kind: "unchanged" };
181 }
182 return {
183 kind: "updated",
184 thread: {
185 ...thread,
186 latestTurn: {
187 ...latestTurn,
188 state: "interrupted",
189 startedAt: latestTurn.startedAt ?? event.payload.createdAt,
190 completedAt: latestTurn.completedAt ?? event.payload.createdAt,
191 },
192 updatedAt: event.occurredAt,
193 },
194 };
195 }
196
197 // ── Messages ────────────────────────────────────────────────────
198 case "thread.message-sent": {
199 const message: OrchestrationMessage = {
200 id: event.payload.messageId,
201 role: event.payload.role,
202 text: event.payload.text,
203 ...(event.payload.attachments !== undefined
204 ? { attachments: event.payload.attachments }
205 : {}),
206 turnId: event.payload.turnId,
207 streaming: event.payload.streaming,
208 createdAt: event.payload.createdAt,
209 updatedAt: event.payload.updatedAt,
210 };
211
212 const existingMessage = thread.messages.find((entry) => entry.id === message.id);
213 const messages = existingMessage
214 ? Arr.map(thread.messages, (entry) =>
215 entry.id !== message.id
216 ? entry
217 : {
218 ...entry,
219 text: message.streaming
220 ? `${entry.text}${message.text}`
221 : message.text.length > 0
222 ? message.text
223 : entry.text,
224 streaming: message.streaming,
225 ...(message.turnId !== undefined ? { turnId: message.turnId } : {}),
226 ...(message.streaming ? {} : { updatedAt: message.updatedAt }),
227 ...(message.attachments !== undefined
228 ? { attachments: message.attachments }
229 : {}),
230 },
231 )
232 : Arr.append(thread.messages, message);
233 const cappedMessages = Arr.takeRight(messages, limits.maxMessages);
234
235 // Update latestTurn for assistant messages bound to a turn.
236 const latestTurn: OrchestrationThread["latestTurn"] =
237 event.payload.role === "assistant" &&
238 event.payload.turnId !== null &&
239 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
240 ? {
241 turnId: event.payload.turnId,
242 state: event.payload.streaming
243 ? "running"
244 : thread.latestTurn?.state === "interrupted"
245 ? "interrupted"
246 : thread.latestTurn?.state === "error"
247 ? "error"
248 : "completed",
249 requestedAt:
250 thread.latestTurn?.turnId === event.payload.turnId
251 ? thread.latestTurn.requestedAt
252 : event.payload.createdAt,
253 startedAt:
254 thread.latestTurn?.turnId === event.payload.turnId
255 ? (thread.latestTurn.startedAt ?? event.payload.createdAt)
256 : event.payload.createdAt,
257 completedAt: event.payload.streaming
258 ? thread.latestTurn?.turnId === event.payload.turnId
259 ? (thread.latestTurn.completedAt ?? null)
260 : null
261 : event.payload.updatedAt,
262 assistantMessageId: event.payload.messageId,
263 }
264 : thread.latestTurn;
265
266 // Rebind checkpoint assistant message IDs for assistant messages.
267 const checkpoints =
268 event.payload.role === "assistant" && event.payload.turnId !== null
269 ? rebindCheckpointAssistantMessage(
270 thread.checkpoints,
271 event.payload.turnId,
272 event.payload.messageId,
273 )
274 : thread.checkpoints;
275
276 return {
277 kind: "updated",
278 thread: {
279 ...thread,
280 messages: cappedMessages,
281 checkpoints,
282 latestTurn,
283 updatedAt: event.occurredAt,
284 },
285 };
286 }
287
288 // ── Session ─────────────────────────────────────────────────────
289 case "thread.session-set": {
290 const latestTurn: OrchestrationLatestTurn | null =
291 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null
292 ? {
293 turnId: event.payload.session.activeTurnId,
294 state: "running",
295 requestedAt:
296 thread.latestTurn?.turnId === event.payload.session.activeTurnId
297 ? thread.latestTurn.requestedAt
298 : event.payload.session.updatedAt,
299 startedAt:
300 thread.latestTurn?.turnId === event.payload.session.activeTurnId
301 ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt)
302 : event.payload.session.updatedAt,
303 completedAt: null,
304 assistantMessageId:
305 thread.latestTurn?.turnId === event.payload.session.activeTurnId
306 ? thread.latestTurn.assistantMessageId
307 : null,
308 }
309 : thread.latestTurn;
310
311 return {
312 kind: "updated",
313 thread: {
314 ...thread,
315 session: event.payload.session,
316 latestTurn,
317 updatedAt: event.occurredAt,
318 },
319 };
320 }
321
322 case "thread.session-stop-requested":
323 return thread.session === null
324 ? { kind: "unchanged" }
325 : {
326 kind: "updated",
327 thread: {
328 ...thread,
329 session: {
330 ...thread.session,
331 status: "stopped",
332 activeTurnId: null,
333 updatedAt: event.payload.createdAt,
334 },
335 updatedAt: event.occurredAt,
336 },
337 };
338
339 // ── Proposed plans ──────────────────────────────────────────────
340 case "thread.proposed-plan-upserted": {
341 const proposedPlan = event.payload.proposedPlan;
342
343 const proposedPlans = pipe(
344 thread.proposedPlans,
345 Arr.filter((entry) => entry.id !== proposedPlan.id),
346 Arr.append(proposedPlan),
347 Arr.sort(proposedPlanOrder),
348 Arr.takeRight(limits.maxProposedPlans),
349 );
350
351 return {
352 kind: "updated",
353 thread: { ...thread, proposedPlans, updatedAt: event.occurredAt },
354 };
355 }
356
357 // ── Checkpoints / turn diffs ────────────────────────────────────
358 case "thread.turn-diff-completed": {
359 const checkpoint: OrchestrationCheckpointSummary = {
360 turnId: event.payload.turnId,
361 checkpointTurnCount: event.payload.checkpointTurnCount,
362 checkpointRef: event.payload.checkpointRef,
363 status: event.payload.status,
364 files: event.payload.files,
365 assistantMessageId: event.payload.assistantMessageId,
366 completedAt: event.payload.completedAt,
367 };
368
369 const existing = thread.checkpoints.find((entry) => entry.turnId === checkpoint.turnId);
370 // Don't overwrite a non-missing checkpoint with a missing one.
371 if (existing && existing.status !== "missing" && checkpoint.status === "missing") {
372 return { kind: "unchanged" };
373 }
374
375 const checkpoints = pipe(
376 thread.checkpoints,
377 Arr.filter((entry) => entry.turnId !== checkpoint.turnId),
378 Arr.append(checkpoint),
379 Arr.sort(checkpointOrder),
380 Arr.takeRight(limits.maxCheckpoints),
381 );
382
383 const latestTurn =
384 thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId
385 ? {
386 turnId: event.payload.turnId,
387 state: checkpointStatusToTurnState(event.payload.status),
388 requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt,
389 startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt,
390 completedAt: event.payload.completedAt,
391 assistantMessageId: event.payload.assistantMessageId,
392 }
393 : thread.latestTurn;
394
395 return {
396 kind: "updated",
397 thread: { ...thread, checkpoints, latestTurn, updatedAt: event.occurredAt },
398 };
399 }
400
401 // ── Revert ──────────────────────────────────────────────────────
402 case "thread.reverted": {
403 const checkpoints = pipe(
404 thread.checkpoints,
405 Arr.filter(
406 (entry) =>
407 entry.checkpointTurnCount !== undefined &&
408 entry.checkpointTurnCount <= event.payload.turnCount,
409 ),
410 Arr.sort(checkpointOrder),
411 Arr.takeRight(limits.maxCheckpoints),
412 );
413
414 const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId));
415 const messages = pipe(
416 retainMessagesAfterRevert(thread.messages, retainedTurnIds),
417 Arr.takeRight(limits.maxMessages),
418 );
419 const proposedPlans = pipe(
420 thread.proposedPlans,
421 Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)),
422 Arr.takeRight(limits.maxProposedPlans),
423 );
424 const activities = pipe(
425 thread.activities,
426 Arr.filter((activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId)),
427 );
428 const latestCheckpoint = checkpoints.at(-1) ?? null;
429
430 return {
431 kind: "updated",
432 thread: {
433 ...thread,
434 checkpoints,
435 messages,
436 proposedPlans,
437 activities,
438 latestTurn:
439 latestCheckpoint === null
440 ? null
441 : {
442 turnId: latestCheckpoint.turnId,
443 state: checkpointStatusToTurnState(
444 latestCheckpoint.status as "ready" | "missing" | "error",
445 ),
446 requestedAt: latestCheckpoint.completedAt,
447 startedAt: latestCheckpoint.completedAt,
448 completedAt: latestCheckpoint.completedAt,
449 assistantMessageId: latestCheckpoint.assistantMessageId ?? null,
450 },
451 updatedAt: event.occurredAt,
452 },
453 };
454 }
455
456 // ── Activities ──────────────────────────────────────────────────
457 case "thread.activity-appended": {
458 const activities = pipe(
459 thread.activities,
460 Arr.filter((activity) => activity.id !== event.payload.activity.id),
461 Arr.append(event.payload.activity),
462 Arr.sort(activityOrder),
463 Arr.takeRight(limits.maxActivities),
464 );
465
466 return {
467 kind: "updated",
468 thread: { ...thread, activities, updatedAt: event.occurredAt },
469 };
470 }
471
472 // ── Events that don't mutate thread state directly ──────────────
473 case "thread.approval-response-requested":
474 case "thread.user-input-response-requested":
475 case "thread.checkpoint-revert-requested":
476 return { kind: "unchanged" };
477 }
478
479 // Forward-compatible: ignore unrecognized event types.
480 return { kind: "unchanged" };
481}
482
483// ── Helpers ──────────────────────────────────────────────────────────
484
485function checkpointStatusToTurnState(
486 status: "ready" | "missing" | "error",
487): OrchestrationLatestTurn["state"] {
488 switch (status) {
489 case "ready":
490 return "completed";
491 case "error":
492 return "error";
493 case "missing":
494 return "completed";
495 }
496}
497
498function rebindCheckpointAssistantMessage(
499 checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
500 turnId: TurnId,
501 messageId: MessageId,
502): OrchestrationCheckpointSummary[] {
503 return Arr.map(checkpoints, (entry) =>
504 entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry,
505 );
506}
507
508function retainMessagesAfterRevert(
509 messages: ReadonlyArray<OrchestrationMessage>,
510 retainedTurnIds: ReadonlySet<string>,
511): OrchestrationMessage[] {
512 // Keep messages that belong to a retained turn, plus system messages and
513 // messages without a turn binding (pre-turn-0 user messages).
514 return Arr.filter(messages, (message) => {
515 if (message.role === "system") {
516 return true;
517 }
518 if (message.turnId === null) {
519 return true;
520 }
521 return retainedTurnIds.has(message.turnId);
522 });
523}
packages/client-runtime/src/threadDetailState.test.ts created+322
...@@ -0,0 +1,322 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import { afterEach, describe, expect, it, vi } from "vitest";
3
4import {
5 EventId,
6 EnvironmentId,
7 MessageId,
8 ProjectId,
9 ProviderInstanceId,
10 ThreadId,
11 TurnId,
12 type OrchestrationThread,
13 type OrchestrationThreadStreamItem,
14} from "@t3tools/contracts";
15
16import { createThreadDetailManager, type ThreadDetailClient } from "./threadDetailState.ts";
17
18let atomRegistry = AtomRegistry.make();
19
20function resetAtomRegistry() {
21 atomRegistry.dispose();
22 atomRegistry = AtomRegistry.make();
23}
24
25function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
26 listeners.add(listener);
27 return () => {
28 listeners.delete(listener);
29 };
30}
31
32const baseEventFields = {
33 eventId: EventId.make("event-1"),
34 commandId: null,
35 causationEventId: null,
36 correlationId: null,
37 metadata: {},
38} as const;
39
40const BASE_THREAD: OrchestrationThread = {
41 id: ThreadId.make("thread-1"),
42 projectId: ProjectId.make("project-1"),
43 title: "Test Thread",
44 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
45 runtimeMode: "full-access",
46 interactionMode: "default",
47 branch: null,
48 worktreePath: null,
49 latestTurn: null,
50 createdAt: "2026-04-01T00:00:00.000Z",
51 updatedAt: "2026-04-01T00:00:00.000Z",
52 archivedAt: null,
53 deletedAt: null,
54 messages: [],
55 proposedPlans: [],
56 activities: [],
57 checkpoints: [],
58 session: null,
59};
60
61const TARGET = {
62 environmentId: EnvironmentId.make("env-local"),
63 threadId: ThreadId.make("thread-1"),
64} as const;
65
66function createMockClient(): {
67 client: ThreadDetailClient;
68 listeners: Set<(event: OrchestrationThreadStreamItem) => void>;
69 emit: (event: OrchestrationThreadStreamItem) => void;
70} {
71 const listeners = new Set<(event: OrchestrationThreadStreamItem) => void>();
72 const client: ThreadDetailClient = {
73 subscribeThread: vi.fn((_input, listener: (event: OrchestrationThreadStreamItem) => void) =>
74 registerListener(listeners, listener),
75 ),
76 };
77
78 return {
79 client,
80 listeners,
81 emit: (event) => {
82 for (const listener of listeners) {
83 listener(event);
84 }
85 },
86 };
87}
88
89describe("createThreadDetailManager", () => {
90 afterEach(() => {
91 vi.useRealTimers();
92 resetAtomRegistry();
93 });
94
95 it("starts in a pending state when watching", () => {
96 const { client } = createMockClient();
97 const manager = createThreadDetailManager({
98 getRegistry: () => atomRegistry,
99 getClient: () => null,
100 });
101
102 manager.watch(TARGET, client);
103
104 expect(manager.getSnapshot(TARGET)).toEqual({
105 data: null,
106 error: null,
107 isPending: true,
108 isDeleted: false,
109 });
110 });
111
112 it("applies snapshots and incremental events", () => {
113 const { client, emit } = createMockClient();
114 const manager = createThreadDetailManager({
115 getRegistry: () => atomRegistry,
116 getClient: () => null,
117 });
118
119 const release = manager.watch(TARGET, client);
120
121 emit({
122 kind: "snapshot",
123 snapshot: {
124 snapshotSequence: 1,
125 thread: BASE_THREAD,
126 },
127 });
128
129 emit({
130 kind: "event",
131 event: {
132 ...baseEventFields,
133 sequence: 2,
134 occurredAt: "2026-04-01T01:00:00.000Z",
135 aggregateKind: "thread",
136 aggregateId: ThreadId.make("thread-1"),
137 type: "thread.message-sent",
138 payload: {
139 threadId: ThreadId.make("thread-1"),
140 turnId: TurnId.make("turn-1"),
141 messageId: MessageId.make("message-1"),
142 role: "assistant",
143 text: "hello",
144 streaming: false,
145 createdAt: "2026-04-01T01:00:00.000Z",
146 updatedAt: "2026-04-01T01:00:00.000Z",
147 },
148 } as any,
149 });
150
151 expect(manager.getSnapshot(TARGET)).toEqual({
152 data: {
153 ...BASE_THREAD,
154 updatedAt: "2026-04-01T01:00:00.000Z",
155 latestTurn: {
156 turnId: TurnId.make("turn-1"),
157 state: "completed",
158 requestedAt: "2026-04-01T01:00:00.000Z",
159 startedAt: "2026-04-01T01:00:00.000Z",
160 completedAt: "2026-04-01T01:00:00.000Z",
161 assistantMessageId: MessageId.make("message-1"),
162 },
163 messages: [
164 {
165 id: MessageId.make("message-1"),
166 role: "assistant",
167 text: "hello",
168 turnId: TurnId.make("turn-1"),
169 streaming: false,
170 createdAt: "2026-04-01T01:00:00.000Z",
171 updatedAt: "2026-04-01T01:00:00.000Z",
172 },
173 ],
174 },
175 error: null,
176 isPending: false,
177 isDeleted: false,
178 });
179
180 release();
181 });
182
183 it("marks threads as deleted when the stream deletes them", () => {
184 const { client, emit } = createMockClient();
185 const manager = createThreadDetailManager({
186 getRegistry: () => atomRegistry,
187 getClient: () => null,
188 });
189
190 const release = manager.watch(TARGET, client);
191
192 emit({
193 kind: "snapshot",
194 snapshot: {
195 snapshotSequence: 1,
196 thread: BASE_THREAD,
197 },
198 });
199 emit({
200 kind: "event",
201 event: {
202 ...baseEventFields,
203 sequence: 3,
204 occurredAt: "2026-04-01T01:10:00.000Z",
205 aggregateKind: "thread",
206 aggregateId: ThreadId.make("thread-1"),
207 type: "thread.deleted",
208 payload: {
209 threadId: ThreadId.make("thread-1"),
210 deletedAt: "2026-04-01T01:10:00.000Z",
211 },
212 } as any,
213 });
214
215 expect(manager.getSnapshot(TARGET)).toEqual({
216 data: null,
217 error: null,
218 isPending: false,
219 isDeleted: true,
220 });
221
222 release();
223 });
224
225 it("waits for delayed client registration when subscribeClientChanges is configured", () => {
226 const connectionListeners = new Set<() => void>();
227 const clients = new Map<string, ReturnType<typeof createMockClient>>();
228 const manager = createThreadDetailManager({
229 getRegistry: () => atomRegistry,
230 getClient: (environmentId) => clients.get(environmentId)?.client ?? null,
231 getClientIdentity: (environmentId) => (clients.has(environmentId) ? environmentId : null),
232 subscribeClientChanges: (listener) => {
233 connectionListeners.add(listener);
234 return () => connectionListeners.delete(listener);
235 },
236 });
237
238 const release = manager.watch(TARGET);
239 expect(manager.getSnapshot(TARGET).isPending).toBe(true);
240
241 const mock = createMockClient();
242 clients.set("env-local", mock);
243 for (const listener of connectionListeners) {
244 listener();
245 }
246
247 mock.emit({
248 kind: "snapshot",
249 snapshot: {
250 snapshotSequence: 1,
251 thread: BASE_THREAD,
252 },
253 });
254
255 expect(manager.getSnapshot(TARGET).data?.id).toBe(ThreadId.make("thread-1"));
256
257 release();
258 });
259
260 it("evicts idle subscriptions after the configured ttl", () => {
261 vi.useFakeTimers();
262 const mock = createMockClient();
263 const manager = createThreadDetailManager({
264 getRegistry: () => atomRegistry,
265 getClient: () => mock.client,
266 retention: {
267 idleTtlMs: 60_000,
268 maxRetainedEntries: 10,
269 },
270 });
271
272 const release = manager.watch(TARGET);
273 expect(mock.listeners.size).toBe(1);
274
275 release();
276 expect(mock.listeners.size).toBe(1);
277
278 vi.advanceTimersByTime(60_000);
279 expect(mock.listeners.size).toBe(0);
280 });
281
282 it("keeps non-idle threads warm when the retention policy says to", () => {
283 vi.useFakeTimers();
284 const mock = createMockClient();
285 const manager = createThreadDetailManager({
286 getRegistry: () => atomRegistry,
287 getClient: () => mock.client,
288 retention: {
289 idleTtlMs: 60_000,
290 maxRetainedEntries: 10,
291 shouldKeepWarm: (_target, state) => state.data?.session?.status === "running",
292 },
293 });
294
295 const release = manager.watch(TARGET);
296 mock.emit({
297 kind: "snapshot",
298 snapshot: {
299 snapshotSequence: 1,
300 thread: {
301 ...BASE_THREAD,
302 session: {
303 threadId: ThreadId.make("thread-1"),
304 status: "running",
305 providerName: "codex",
306 runtimeMode: "full-access",
307 activeTurnId: TurnId.make("turn-1"),
308 lastError: null,
309 updatedAt: "2026-04-01T00:10:00.000Z",
310 },
311 },
312 },
313 });
314
315 release();
316 vi.advanceTimersByTime(60_000);
317
318 expect(mock.listeners.size).toBe(1);
319 manager.reset();
320 expect(mock.listeners.size).toBe(0);
321 });
322});
packages/client-runtime/src/threadDetailState.ts created+444
...@@ -0,0 +1,444 @@
1import { pipe } from "effect/Function";
2import * as Order from "effect/Order";
3import * as Arr from "effect/Array";
4import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import type {
9 OrchestrationThread,
10 OrchestrationThreadStreamItem,
11 EnvironmentId,
12 ThreadId as ThreadIdType,
13} from "@t3tools/contracts";
14import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
15
16import {
17 DEFAULT_THREAD_DETAIL_LIMITS,
18 applyThreadDetailEvent,
19 type ThreadDetailRetentionLimits,
20} from "./threadDetailReducer.ts";
21import type { WsRpcClient } from "./wsRpcClient.ts";
22
23export interface ThreadDetailState {
24 readonly data: OrchestrationThread | null;
25 readonly error: string | null;
26 readonly isPending: boolean;
27 readonly isDeleted: boolean;
28}
29
30export interface ThreadDetailTarget {
31 readonly environmentId: EnvironmentId | null;
32 readonly threadId: ThreadIdType | null;
33}
34
35export type ThreadDetailClient = Pick<WsRpcClient["orchestration"], "subscribeThread">;
36
37export interface ThreadDetailRetentionPolicy {
38 readonly idleTtlMs: number;
39 readonly maxRetainedEntries: number;
40 readonly shouldKeepWarm?: (
41 target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType },
42 state: ThreadDetailState,
43 ) => boolean;
44}
45
46interface ThreadDetailEntry {
47 readonly target: {
48 readonly environmentId: EnvironmentId;
49 readonly threadId: ThreadIdType;
50 };
51 watcherCount: number;
52 retainCount: number;
53 teardown: () => void;
54 lastAccessedAt: number;
55 evictionFiber: Fiber.Fiber<void, never> | null;
56}
57
58const NOOP: () => void = () => undefined;
59const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe());
60
61function clearEntryEviction(entry: ThreadDetailEntry): void {
62 if (entry.evictionFiber !== null) {
63 Effect.runFork(Fiber.interrupt(entry.evictionFiber));
64 entry.evictionFiber = null;
65 }
66}
67
68export const EMPTY_THREAD_DETAIL_STATE = Object.freeze<ThreadDetailState>({
69 data: null,
70 error: null,
71 isPending: false,
72 isDeleted: false,
73});
74
75const INITIAL_THREAD_DETAIL_STATE = Object.freeze<ThreadDetailState>({
76 data: null,
77 error: null,
78 isPending: true,
79 isDeleted: false,
80});
81
82const knownThreadDetailKeys = new Set<string>();
83
84export const threadDetailStateAtom = Atom.family((key: string) => {
85 knownThreadDetailKeys.add(key);
86 return Atom.make(INITIAL_THREAD_DETAIL_STATE).pipe(
87 Atom.keepAlive,
88 Atom.withLabel(`thread-detail:${key}`),
89 );
90});
91
92export const EMPTY_THREAD_DETAIL_ATOM = Atom.make(EMPTY_THREAD_DETAIL_STATE).pipe(
93 Atom.keepAlive,
94 Atom.withLabel("thread-detail:null"),
95);
96
97export function getThreadDetailTargetKey(target: ThreadDetailTarget): string | null {
98 if (target.environmentId === null || target.threadId === null) {
99 return null;
100 }
101
102 return `${target.environmentId}:${target.threadId}`;
103}
104
105export interface ThreadDetailManagerConfig {
106 readonly getRegistry: () => AtomRegistry.AtomRegistry;
107 readonly getClient: (environmentId: EnvironmentId) => ThreadDetailClient | null;
108 readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null;
109 readonly subscribeClientChanges?: (listener: () => void) => () => void;
110 readonly limits?: ThreadDetailRetentionLimits;
111 readonly retention?: ThreadDetailRetentionPolicy;
112}
113
114export function createThreadDetailManager(config: ThreadDetailManagerConfig) {
115 const entries = new Map<string, ThreadDetailEntry>();
116
117 function getSnapshot(target: ThreadDetailTarget): ThreadDetailState {
118 const targetKey = getThreadDetailTargetKey(target);
119 if (targetKey === null) {
120 return EMPTY_THREAD_DETAIL_STATE;
121 }
122
123 return config.getRegistry().get(threadDetailStateAtom(targetKey));
124 }
125
126 function setState(targetKey: string, nextState: ThreadDetailState): void {
127 config.getRegistry().set(threadDetailStateAtom(targetKey), nextState);
128 reconcileRetention(targetKey);
129 }
130
131 function markPending(targetKey: string): void {
132 const current = config.getRegistry().get(threadDetailStateAtom(targetKey));
133 setState(targetKey, {
134 ...current,
135 error: null,
136 isPending: true,
137 });
138 }
139
140 function setData(targetKey: string, thread: OrchestrationThread): void {
141 setState(targetKey, {
142 data: thread,
143 error: null,
144 isPending: false,
145 isDeleted: false,
146 });
147 }
148
149 function setDeleted(targetKey: string): void {
150 setState(targetKey, {
151 data: null,
152 error: null,
153 isPending: false,
154 isDeleted: true,
155 });
156 }
157
158 function shouldKeepWarm(entry: ThreadDetailEntry): boolean {
159 return config.retention?.shouldKeepWarm?.(entry.target, getSnapshot(entry.target)) ?? false;
160 }
161
162 function disposeEntry(targetKey: string): void {
163 const entry = entries.get(targetKey);
164 if (!entry) {
165 return;
166 }
167
168 clearEntryEviction(entry);
169 entry.teardown();
170 entries.delete(targetKey);
171 }
172
173 function evictIdleEntriesToCapacity(): void {
174 const retention = config.retention;
175 if (!retention || entries.size <= retention.maxRetainedEntries) {
176 return;
177 }
178
179 const idleEntries = pipe(
180 Arr.fromIterable(entries),
181 Arr.filter(
182 ([, entry]) =>
183 entry.watcherCount === 0 && entry.retainCount === 0 && !shouldKeepWarm(entry),
184 ),
185 Arr.sortWith(([, e]) => e.lastAccessedAt, Order.Number),
186 );
187
188 for (const [targetKey] of idleEntries) {
189 if (entries.size <= retention.maxRetainedEntries) {
190 return;
191 }
192 disposeEntry(targetKey);
193 }
194 }
195
196 function scheduleEviction(targetKey: string, entry: ThreadDetailEntry): void {
197 const retention = config.retention;
198 clearEntryEviction(entry);
199
200 if (!retention) {
201 disposeEntry(targetKey);
202 return;
203 }
204
205 if (retention.idleTtlMs <= 0) {
206 disposeEntry(targetKey);
207 return;
208 }
209
210 entry.evictionFiber = Effect.runFork(
211 Effect.sleep(Duration.millis(retention.idleTtlMs)).pipe(
212 Effect.andThen(
213 Effect.sync(() => {
214 const current = entries.get(targetKey);
215 if (!current) {
216 return;
217 }
218
219 current.evictionFiber = null;
220 if (current.watcherCount > 0 || current.retainCount > 0 || shouldKeepWarm(current)) {
221 return;
222 }
223
224 disposeEntry(targetKey);
225 }),
226 ),
227 ),
228 );
229 }
230
231 function reconcileRetention(targetKey: string): void {
232 const entry = entries.get(targetKey);
233 if (!entry) {
234 return;
235 }
236
237 clearEntryEviction(entry);
238 if (entry.watcherCount > 0 || entry.retainCount > 0 || shouldKeepWarm(entry)) {
239 return;
240 }
241
242 scheduleEviction(targetKey, entry);
243 evictIdleEntriesToCapacity();
244 }
245
246 function applyStreamItem(
247 targetKey: string,
248 item: OrchestrationThreadStreamItem,
249 threadId: ThreadIdType,
250 ): void {
251 if (item.kind === "snapshot") {
252 setData(targetKey, item.snapshot.thread);
253 return;
254 }
255
256 const current = getSnapshot({
257 environmentId: entries.get(targetKey)?.target.environmentId ?? null,
258 threadId,
259 }).data;
260
261 if (current === null) {
262 if (item.event.type === "thread.deleted") {
263 setDeleted(targetKey);
264 }
265 return;
266 }
267
268 const result = applyThreadDetailEvent(
269 current,
270 item.event,
271 config.limits ?? DEFAULT_THREAD_DETAIL_LIMITS,
272 );
273
274 if (result.kind === "updated") {
275 setData(targetKey, result.thread);
276 return;
277 }
278
279 if (result.kind === "deleted") {
280 setDeleted(targetKey);
281 }
282 }
283
284 function subscribeStream(
285 targetKey: string,
286 target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType },
287 client: ThreadDetailClient,
288 ): () => void {
289 markPending(targetKey);
290 return client.subscribeThread(
291 { threadId: target.threadId },
292 (item) => applyStreamItem(targetKey, item, target.threadId),
293 {
294 onResubscribe: () => markPending(targetKey),
295 },
296 );
297 }
298
299 function createDynamicSubscription(
300 targetKey: string,
301 target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType },
302 ): () => void {
303 let currentIdentity: string | null = null;
304 let currentUnsub = NOOP;
305
306 const sync = () => {
307 const client = config.getClient(target.environmentId);
308 const identity = client
309 ? (config.getClientIdentity?.(target.environmentId) ?? target.environmentId)
310 : null;
311
312 if (!client || identity === null) {
313 if (currentIdentity !== null) {
314 currentUnsub();
315 currentUnsub = NOOP;
316 currentIdentity = null;
317 }
318 markPending(targetKey);
319 return;
320 }
321
322 if (currentIdentity === identity) {
323 return;
324 }
325
326 currentUnsub();
327 currentIdentity = identity;
328 currentUnsub = subscribeStream(targetKey, target, client);
329 };
330
331 const unsubChanges = config.subscribeClientChanges!(sync);
332 sync();
333
334 return () => {
335 unsubChanges();
336 currentUnsub();
337 };
338 }
339
340 function acquire(
341 target: ThreadDetailTarget,
342 kind: "watcher" | "retain",
343 client?: ThreadDetailClient,
344 ): () => void {
345 const targetKey = getThreadDetailTargetKey(target);
346 if (targetKey === null || target.environmentId === null || target.threadId === null) {
347 return NOOP;
348 }
349
350 const existing = entries.get(targetKey);
351 if (existing) {
352 clearEntryEviction(existing);
353 existing.lastAccessedAt = nowMs();
354 if (kind === "watcher") {
355 existing.watcherCount += 1;
356 } else {
357 existing.retainCount += 1;
358 }
359 return () => release(targetKey, kind);
360 }
361
362 let teardown: () => void;
363 const resolvedTarget = {
364 environmentId: target.environmentId,
365 threadId: target.threadId,
366 };
367
368 if (client) {
369 teardown = subscribeStream(targetKey, resolvedTarget, client);
370 } else if (config.subscribeClientChanges) {
371 teardown = createDynamicSubscription(targetKey, resolvedTarget);
372 } else {
373 const resolved = config.getClient(target.environmentId);
374 if (!resolved) {
375 return NOOP;
376 }
377 teardown = subscribeStream(targetKey, resolvedTarget, resolved);
378 }
379
380 entries.set(targetKey, {
381 target: resolvedTarget,
382 watcherCount: kind === "watcher" ? 1 : 0,
383 retainCount: kind === "retain" ? 1 : 0,
384 teardown,
385 lastAccessedAt: nowMs(),
386 evictionFiber: null,
387 });
388 evictIdleEntriesToCapacity();
389 return () => release(targetKey, kind);
390 }
391
392 function release(targetKey: string, kind: "watcher" | "retain"): void {
393 const entry = entries.get(targetKey);
394 if (!entry) {
395 return;
396 }
397
398 if (kind === "watcher") {
399 entry.watcherCount = Math.max(0, entry.watcherCount - 1);
400 } else {
401 entry.retainCount = Math.max(0, entry.retainCount - 1);
402 }
403 entry.lastAccessedAt = nowMs();
404 reconcileRetention(targetKey);
405 }
406
407 function watch(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void {
408 return acquire(target, "watcher", client);
409 }
410
411 function retain(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void {
412 return acquire(target, "retain", client);
413 }
414
415 function invalidate(target?: ThreadDetailTarget): void {
416 if (target) {
417 const targetKey = getThreadDetailTargetKey(target);
418 if (targetKey !== null) {
419 disposeEntry(targetKey);
420 config.getRegistry().set(threadDetailStateAtom(targetKey), EMPTY_THREAD_DETAIL_STATE);
421 }
422 return;
423 }
424
425 for (const targetKey of entries.keys()) {
426 disposeEntry(targetKey);
427 }
428 for (const key of knownThreadDetailKeys) {
429 config.getRegistry().set(threadDetailStateAtom(key), EMPTY_THREAD_DETAIL_STATE);
430 }
431 }
432
433 function reset(): void {
434 invalidate();
435 }
436
437 return {
438 watch,
439 retain,
440 getSnapshot,
441 invalidate,
442 reset,
443 };
444}
packages/client-runtime/src/transportError.test.ts created+53
...@@ -0,0 +1,53 @@
1import { describe, expect, it } from "vitest";
2
3import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError.ts";
4
5describe("isTransportConnectionErrorMessage", () => {
6 it("returns true for SocketCloseError", () => {
7 expect(isTransportConnectionErrorMessage("SocketCloseError: connection reset")).toBe(true);
8 });
9
10 it("returns true for SocketOpenError", () => {
11 expect(isTransportConnectionErrorMessage("SocketOpenError: ECONNREFUSED")).toBe(true);
12 });
13
14 it("returns true for the T3 server WebSocket message", () => {
15 expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe(
16 true,
17 );
18 });
19
20 it("returns true for ping timeout", () => {
21 expect(isTransportConnectionErrorMessage("ping timeout")).toBe(true);
22 });
23
24 it("returns false for business logic errors", () => {
25 expect(isTransportConnectionErrorMessage("Thread not found")).toBe(false);
26 expect(isTransportConnectionErrorMessage("Invalid model selection")).toBe(false);
27 });
28
29 it("returns false for null, undefined, and empty strings", () => {
30 expect(isTransportConnectionErrorMessage(null)).toBe(false);
31 expect(isTransportConnectionErrorMessage(undefined)).toBe(false);
32 expect(isTransportConnectionErrorMessage("")).toBe(false);
33 expect(isTransportConnectionErrorMessage(" ")).toBe(false);
34 });
35});
36
37describe("sanitizeThreadErrorMessage", () => {
38 it("strips transport errors", () => {
39 expect(sanitizeThreadErrorMessage("SocketCloseError: oops")).toBeNull();
40 });
41
42 it("preserves non-transport errors", () => {
43 expect(sanitizeThreadErrorMessage("Thread not found")).toBe("Thread not found");
44 expect(sanitizeThreadErrorMessage("Select a base branch before sending.")).toBe(
45 "Select a base branch before sending.",
46 );
47 });
48
49 it("returns null for null/undefined", () => {
50 expect(sanitizeThreadErrorMessage(null)).toBeNull();
51 expect(sanitizeThreadErrorMessage(undefined)).toBeNull();
52 });
53});
packages/client-runtime/src/transportError.ts created+33
...@@ -0,0 +1,33 @@
1const TRANSPORT_ERROR_PATTERNS = [
2 /\bSocketCloseError\b/i,
3 /\bSocketOpenError\b/i,
4 /Unable to connect to the T3 server WebSocket\./i,
5 /\bping timeout\b/i,
6] as const;
7
8/**
9 * Test whether an error message originates from a transport-level connection
10 * failure (socket close, socket open, ping timeout, etc.) rather than a
11 * business-logic error.
12 */
13export function isTransportConnectionErrorMessage(message: string | null | undefined): boolean {
14 if (typeof message !== "string") {
15 return false;
16 }
17
18 const normalizedMessage = message.trim();
19 if (normalizedMessage.length === 0) {
20 return false;
21 }
22
23 return TRANSPORT_ERROR_PATTERNS.some((pattern) => pattern.test(normalizedMessage));
24}
25
26/**
27 * Strip transport connection errors from user-facing error messages.
28 * Returns `null` for transport errors so the UI can distinguish between
29 * real errors and transient connection issues.
30 */
31export function sanitizeThreadErrorMessage(message: string | null | undefined): string | null {
32 return isTransportConnectionErrorMessage(message) ? null : (message ?? null);
33}
packages/client-runtime/src/vcsActionState.test.ts created+292
...@@ -0,0 +1,292 @@
1import {
2 EnvironmentId,
3 type GitActionProgressEvent,
4 type GitRunStackedActionResult,
5 type VcsCreateRefResult,
6 type VcsCreateWorktreeResult,
7 type VcsPullResult,
8 type VcsStatusResult,
9 type VcsSwitchRefResult,
10} from "@t3tools/contracts";
11import { AtomRegistry } from "effect/unstable/reactivity";
12import { afterEach, describe, expect, it, vi } from "vitest";
13
14import {
15 type VcsActionClient,
16 createVcsActionManager,
17 EMPTY_VCS_ACTION_STATE,
18} from "./vcsActionState.ts";
19
20let atomRegistry = AtomRegistry.make();
21
22function resetAtomRegistry() {
23 atomRegistry.dispose();
24 atomRegistry = AtomRegistry.make();
25}
26
27function createDeferred<T>() {
28 let resolve!: (value: T) => void;
29 let reject!: (reason?: unknown) => void;
30 const promise = new Promise<T>((res, rej) => {
31 resolve = res;
32 reject = rej;
33 });
34 return { promise, resolve, reject };
35}
36
37const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
38
39const BASE_STATUS: VcsStatusResult = {
40 isRepo: true,
41 hasPrimaryRemote: true,
42 isDefaultRef: false,
43 refName: "feature/test",
44 hasWorkingTreeChanges: true,
45 workingTree: { files: [], insertions: 0, deletions: 0 },
46 hasUpstream: true,
47 aheadCount: 0,
48 behindCount: 0,
49 pr: null,
50};
51
52function createPhaseStartedEvent(): Extract<GitActionProgressEvent, { kind: "phase_started" }> {
53 return {
54 actionId: "action-123",
55 cwd: "/repo",
56 action: "commit_push",
57 kind: "phase_started",
58 phase: "commit",
59 label: "Committing...",
60 };
61}
62
63function createHookStartedEvent(): Extract<GitActionProgressEvent, { kind: "hook_started" }> {
64 return {
65 actionId: "action-123",
66 cwd: "/repo",
67 action: "commit_push",
68 kind: "hook_started",
69 hookName: "post-commit",
70 };
71}
72
73function createHookOutputEvent(): Extract<GitActionProgressEvent, { kind: "hook_output" }> {
74 return {
75 actionId: "action-123",
76 cwd: "/repo",
77 action: "commit_push",
78 kind: "hook_output",
79 hookName: "post-commit",
80 stream: "stdout",
81 text: "hook output",
82 };
83}
84
85function createHookFinishedEvent(): Extract<GitActionProgressEvent, { kind: "hook_finished" }> {
86 return {
87 actionId: "action-123",
88 cwd: "/repo",
89 action: "commit_push",
90 kind: "hook_finished",
91 hookName: "post-commit",
92 exitCode: 0,
93 durationMs: 12,
94 };
95}
96
97function createActionFinishedEvent(): Extract<GitActionProgressEvent, { kind: "action_finished" }> {
98 return {
99 actionId: "action-123",
100 cwd: "/repo",
101 action: "commit_push",
102 kind: "action_finished",
103 result: {
104 action: "commit_push",
105 branch: { status: "skipped_not_requested" },
106 commit: { status: "created", commitSha: "abc123", subject: "Test commit" },
107 push: {
108 status: "pushed",
109 branch: "feature/test",
110 upstreamBranch: "origin/feature/test",
111 },
112 pr: { status: "skipped_not_requested" },
113 toast: {
114 title: "Done",
115 description: "Action finished",
116 cta: { kind: "none" },
117 },
118 } satisfies GitRunStackedActionResult,
119 };
120}
121
122function createMockClient() {
123 const refreshDeferred = createDeferred<VcsStatusResult>();
124 const pullDeferred = createDeferred<VcsPullResult>();
125 const switchRefDeferred = createDeferred<VcsSwitchRefResult>();
126 const createRefDeferred = createDeferred<VcsCreateRefResult>();
127 const createWorktreeDeferred = createDeferred<VcsCreateWorktreeResult>();
128 const initDeferred = createDeferred<void>();
129 const runChangeRequestDeferred = createDeferred<GitRunStackedActionResult>();
130 let runChangeRequestProgressListener: ((event: GitActionProgressEvent) => void) | null = null;
131
132 const client: VcsActionClient = {
133 refreshStatus: vi.fn(() => refreshDeferred.promise),
134 pull: vi.fn(() => pullDeferred.promise),
135 switchRef: vi.fn(() => switchRefDeferred.promise),
136 createRef: vi.fn(() => createRefDeferred.promise),
137 createWorktree: vi.fn(() => createWorktreeDeferred.promise),
138 init: vi.fn(() => initDeferred.promise),
139 runChangeRequest: vi.fn((_, options) => {
140 runChangeRequestProgressListener = options?.onProgress ?? null;
141 return runChangeRequestDeferred.promise;
142 }),
143 };
144
145 return {
146 client,
147 refreshDeferred,
148 pullDeferred,
149 switchRefDeferred,
150 createRefDeferred,
151 createWorktreeDeferred,
152 initDeferred,
153 runChangeRequestDeferred,
154 emitProgress(event: GitActionProgressEvent) {
155 runChangeRequestProgressListener?.(event);
156 },
157 };
158}
159
160describe("createVcsActionManager", () => {
161 afterEach(() => {
162 resetAtomRegistry();
163 });
164
165 it("tracks refreshStatus progress and clears state on success", async () => {
166 const mock = createMockClient();
167 const manager = createVcsActionManager({
168 getRegistry: () => atomRegistry,
169 getClient: () => mock.client,
170 });
171
172 const promise = manager.refreshStatus(TARGET, mock.client);
173
174 expect(manager.getSnapshot(TARGET)).toMatchObject({
175 isRunning: true,
176 operation: "refresh_status",
177 currentLabel: "Refreshing source control status",
178 error: null,
179 });
180
181 mock.refreshDeferred.resolve(BASE_STATUS);
182
183 await expect(promise).resolves.toEqual(BASE_STATUS);
184 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE);
185 });
186
187 it("tracks runChangeRequest progress events", async () => {
188 const mock = createMockClient();
189 const onProgress = vi.fn();
190 const manager = createVcsActionManager({
191 getRegistry: () => atomRegistry,
192 getClient: () => mock.client,
193 getActionId: () => "action-123",
194 });
195
196 const promise = manager.runChangeRequest(
197 TARGET,
198 { action: "commit_push", commitMessage: "Test commit" },
199 { client: mock.client, gitStatus: BASE_STATUS, onProgress },
200 );
201
202 expect(manager.getSnapshot(TARGET)).toMatchObject({
203 isRunning: true,
204 operation: "run_change_request",
205 actionId: "action-123",
206 currentLabel: "Committing...",
207 error: null,
208 });
209
210 mock.emitProgress(createPhaseStartedEvent());
211 expect(manager.getSnapshot(TARGET).currentLabel).toBe("Committing...");
212 expect(onProgress).toHaveBeenLastCalledWith(createPhaseStartedEvent());
213
214 mock.emitProgress(createHookStartedEvent());
215 expect(manager.getSnapshot(TARGET)).toMatchObject({
216 currentLabel: "Running post-commit...",
217 hookName: "post-commit",
218 isRunning: true,
219 });
220
221 mock.emitProgress(createHookOutputEvent());
222 expect(manager.getSnapshot(TARGET).lastOutputLine).toBe("hook output");
223
224 mock.emitProgress(createHookFinishedEvent());
225 expect(manager.getSnapshot(TARGET)).toMatchObject({
226 currentLabel: "Committing...",
227 hookName: null,
228 lastOutputLine: null,
229 });
230
231 const result = createActionFinishedEvent().result;
232 mock.runChangeRequestDeferred.resolve(result);
233
234 await expect(promise).resolves.toEqual(result);
235 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE);
236 });
237
238 it("stores the error when an operation fails", async () => {
239 const mock = createMockClient();
240 const manager = createVcsActionManager({
241 getRegistry: () => atomRegistry,
242 getClient: () => mock.client,
243 });
244
245 const promise = manager.pull(TARGET, mock.client);
246
247 mock.pullDeferred.reject(new Error("Pull failed."));
248
249 await expect(promise).rejects.toThrow("Pull failed.");
250 expect(manager.getSnapshot(TARGET)).toMatchObject({
251 isRunning: false,
252 operation: "pull",
253 currentLabel: null,
254 error: "Pull failed.",
255 });
256 });
257
258 it("invalidates after successful mutations but not refreshStatus", async () => {
259 const mock = createMockClient();
260 const onInvalidate = vi.fn();
261 const manager = createVcsActionManager({
262 getRegistry: () => atomRegistry,
263 getClient: () => mock.client,
264 onInvalidate,
265 });
266
267 const refreshPromise = manager.refreshStatus(TARGET, mock.client);
268 mock.refreshDeferred.resolve(BASE_STATUS);
269 await expect(refreshPromise).resolves.toEqual(BASE_STATUS);
270 expect(onInvalidate).not.toHaveBeenCalled();
271
272 const pullPromise = manager.pull(TARGET, mock.client);
273 const pullResult: VcsPullResult = {
274 status: "skipped_up_to_date",
275 refName: "main",
276 upstreamRef: null,
277 };
278 mock.pullDeferred.resolve(pullResult);
279 await expect(pullPromise).resolves.toEqual(pullResult);
280 expect(onInvalidate).toHaveBeenCalledWith(TARGET);
281 });
282
283 it("returns null when no client is available", async () => {
284 const manager = createVcsActionManager({
285 getRegistry: () => atomRegistry,
286 getClient: () => null,
287 });
288
289 await expect(manager.switchRef(TARGET, { refName: "main" })).resolves.toBeNull();
290 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE);
291 });
292});
packages/client-runtime/src/vcsActionState.ts created+458
...@@ -0,0 +1,458 @@
1import type {
2 GitActionProgressEvent,
3 GitRunStackedActionInput,
4 GitRunStackedActionResult,
5 GitStackedAction,
6 EnvironmentId,
7 VcsCreateRefInput,
8 VcsCreateRefResult,
9 VcsCreateWorktreeInput,
10 VcsCreateWorktreeResult,
11 VcsPullInput,
12 VcsPullResult,
13 VcsStatusResult,
14 VcsSwitchRefInput,
15 VcsSwitchRefResult,
16} from "@t3tools/contracts";
17import * as DateTime from "effect/DateTime";
18import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
19
20import { buildGitActionProgressStages } from "./gitActions.ts";
21import type { WsRpcClient } from "./wsRpcClient.ts";
22
23export type VcsActionOperation =
24 | "refresh_status"
25 | "run_change_request"
26 | "pull"
27 | "switch_ref"
28 | "create_ref"
29 | "create_worktree"
30 | "init";
31
32export interface VcsActionState {
33 readonly isRunning: boolean;
34 readonly operation: VcsActionOperation | null;
35 readonly actionId: string | null;
36 readonly action: GitStackedAction | null;
37 readonly currentLabel: string | null;
38 readonly currentPhaseLabel: string | null;
39 readonly hookName: string | null;
40 readonly lastOutputLine: string | null;
41 readonly phaseStartedAtMs: number | null;
42 readonly hookStartedAtMs: number | null;
43 readonly error: string | null;
44}
45
46export interface VcsActionTarget {
47 readonly environmentId: EnvironmentId | null;
48 readonly cwd: string | null;
49}
50
51export type VcsActionClient = Pick<
52 WsRpcClient["vcs"],
53 "refreshStatus" | "pull" | "switchRef" | "createRef" | "createWorktree" | "init"
54> & {
55 readonly runChangeRequest: WsRpcClient["git"]["runStackedAction"];
56};
57
58export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({
59 isRunning: false,
60 operation: null,
61 actionId: null,
62 action: null,
63 currentLabel: null,
64 currentPhaseLabel: null,
65 hookName: null,
66 lastOutputLine: null,
67 phaseStartedAtMs: null,
68 hookStartedAtMs: null,
69 error: null,
70});
71
72const knownVcsActionKeys = new Set<string>();
73let nextGeneratedActionId = 0;
74const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe());
75
76export const vcsActionStateAtom = Atom.family((key: string) => {
77 knownVcsActionKeys.add(key);
78 return Atom.make(EMPTY_VCS_ACTION_STATE).pipe(
79 Atom.keepAlive,
80 Atom.withLabel(`vcs-action:${key}`),
81 );
82});
83
84export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe(
85 Atom.keepAlive,
86 Atom.withLabel("vcs-action:null"),
87);
88
89export function getVcsActionTargetKey(target: VcsActionTarget): string | null {
90 if (target.environmentId === null || target.cwd === null) {
91 return null;
92 }
93 return `${target.environmentId}:${target.cwd}`;
94}
95
96export function applyVcsActionProgressEvent(
97 current: VcsActionState,
98 event: GitActionProgressEvent,
99): VcsActionState {
100 const now = nowMs();
101
102 switch (event.kind) {
103 case "action_started":
104 return {
105 ...current,
106 isRunning: true,
107 actionId: event.actionId,
108 action: event.action,
109 operation: "run_change_request",
110 phaseStartedAtMs: now,
111 hookStartedAtMs: null,
112 hookName: null,
113 lastOutputLine: null,
114 error: null,
115 };
116 case "phase_started":
117 return {
118 ...current,
119 isRunning: true,
120 actionId: event.actionId,
121 action: event.action,
122 operation: "run_change_request",
123 currentLabel: event.label,
124 currentPhaseLabel: event.label,
125 phaseStartedAtMs: now,
126 hookStartedAtMs: null,
127 hookName: null,
128 lastOutputLine: null,
129 error: null,
130 };
131 case "hook_started":
132 return {
133 ...current,
134 isRunning: true,
135 actionId: event.actionId,
136 action: event.action,
137 operation: "run_change_request",
138 currentLabel: `Running ${event.hookName}...`,
139 hookName: event.hookName,
140 hookStartedAtMs: now,
141 lastOutputLine: null,
142 error: null,
143 };
144 case "hook_output":
145 return {
146 ...current,
147 isRunning: true,
148 actionId: event.actionId,
149 action: event.action,
150 operation: "run_change_request",
151 lastOutputLine: event.text,
152 error: null,
153 };
154 case "hook_finished":
155 return {
156 ...current,
157 isRunning: true,
158 actionId: event.actionId,
159 action: event.action,
160 operation: "run_change_request",
161 currentLabel: current.currentPhaseLabel,
162 hookName: null,
163 hookStartedAtMs: null,
164 lastOutputLine: null,
165 error: null,
166 };
167 case "action_finished":
168 return {
169 ...current,
170 isRunning: false,
171 actionId: event.actionId,
172 action: event.action,
173 operation: "run_change_request",
174 error: null,
175 };
176 case "action_failed":
177 return {
178 ...EMPTY_VCS_ACTION_STATE,
179 actionId: event.actionId,
180 action: event.action,
181 operation: "run_change_request",
182 error: event.message,
183 };
184 }
185}
186
187export interface VcsActionManagerConfig {
188 readonly getRegistry: () => AtomRegistry.AtomRegistry;
189 readonly getClient: (environmentId: EnvironmentId) => VcsActionClient | null;
190 readonly getActionId?: () => string;
191 readonly onInvalidate?: (target: VcsActionTarget) => void | Promise<void>;
192}
193
194export function createVcsActionManager(config: VcsActionManagerConfig) {
195 function setState(targetKey: string, nextState: VcsActionState): void {
196 config.getRegistry().set(vcsActionStateAtom(targetKey), nextState);
197 }
198
199 function startOperation(
200 targetKey: string,
201 input: {
202 readonly operation: VcsActionOperation;
203 readonly actionId?: string;
204 readonly action?: GitStackedAction;
205 readonly label: string;
206 },
207 ): void {
208 setState(targetKey, {
209 isRunning: true,
210 operation: input.operation,
211 actionId: input.actionId ?? null,
212 action: input.action ?? null,
213 currentLabel: input.label,
214 currentPhaseLabel: input.label,
215 hookName: null,
216 lastOutputLine: null,
217 phaseStartedAtMs: nowMs(),
218 hookStartedAtMs: null,
219 error: null,
220 });
221 }
222
223 function finishOperation(targetKey: string): void {
224 setState(targetKey, EMPTY_VCS_ACTION_STATE);
225 }
226
227 function failOperation(
228 targetKey: string,
229 error: unknown,
230 input: {
231 readonly operation: VcsActionOperation;
232 readonly actionId?: string;
233 readonly action?: GitStackedAction;
234 },
235 ): void {
236 setState(targetKey, {
237 ...EMPTY_VCS_ACTION_STATE,
238 operation: input.operation,
239 actionId: input.actionId ?? null,
240 action: input.action ?? null,
241 error: error instanceof Error ? error.message : "Source control action failed.",
242 });
243 }
244
245 async function runOperation<TResult>(
246 target: VcsActionTarget,
247 input: {
248 readonly operation: VcsActionOperation;
249 readonly label: string;
250 readonly actionId?: string;
251 readonly action?: GitStackedAction;
252 readonly client?: VcsActionClient | undefined;
253 readonly invalidateOnSuccess?: boolean;
254 readonly execute: (client: VcsActionClient) => Promise<TResult>;
255 },
256 ): Promise<TResult | null> {
257 const targetKey = getVcsActionTargetKey(target);
258 if (targetKey === null || target.environmentId === null || target.cwd === null) {
259 return null;
260 }
261
262 const resolved = input.client ?? config.getClient(target.environmentId);
263 if (!resolved) {
264 return null;
265 }
266
267 startOperation(targetKey, input);
268 try {
269 const result = await input.execute(resolved);
270 finishOperation(targetKey);
271 if (input.invalidateOnSuccess ?? true) {
272 await config.onInvalidate?.(target);
273 }
274 return result;
275 } catch (error) {
276 failOperation(targetKey, error, input);
277 throw error;
278 }
279 }
280
281 function getSnapshot(target: VcsActionTarget): VcsActionState {
282 const targetKey = getVcsActionTargetKey(target);
283 if (targetKey === null) {
284 return EMPTY_VCS_ACTION_STATE;
285 }
286
287 return config.getRegistry().get(vcsActionStateAtom(targetKey));
288 }
289
290 async function refreshStatus(
291 target: VcsActionTarget,
292 client?: VcsActionClient,
293 options?: { readonly quiet?: boolean },
294 ): Promise<Awaited<ReturnType<VcsActionClient["refreshStatus"]>> | null> {
295 if (options?.quiet) {
296 if (target.environmentId === null || target.cwd === null) {
297 return null;
298 }
299 const resolved = client ?? config.getClient(target.environmentId);
300 return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null;
301 }
302
303 return runOperation(target, {
304 operation: "refresh_status",
305 label: "Refreshing source control status",
306 client,
307 invalidateOnSuccess: false,
308 execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }),
309 });
310 }
311
312 async function pull(
313 target: VcsActionTarget,
314 client?: VcsActionClient,
315 options?: { readonly label?: string },
316 ): Promise<VcsPullResult | null> {
317 return runOperation(target, {
318 operation: "pull",
319 label: options?.label ?? "Pulling latest changes",
320 client,
321 execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies VcsPullInput),
322 });
323 }
324
325 async function switchRef(
326 target: VcsActionTarget,
327 input: Omit<VcsSwitchRefInput, "cwd">,
328 client?: VcsActionClient,
329 options?: { readonly label?: string },
330 ): Promise<VcsSwitchRefResult | null> {
331 return runOperation(target, {
332 operation: "switch_ref",
333 label: options?.label ?? "Switching branch",
334 client,
335 execute: (resolved) => resolved.switchRef({ cwd: target.cwd!, ...input }),
336 });
337 }
338
339 async function createRef(
340 target: VcsActionTarget,
341 input: Omit<VcsCreateRefInput, "cwd">,
342 client?: VcsActionClient,
343 options?: { readonly label?: string },
344 ): Promise<VcsCreateRefResult | null> {
345 return runOperation(target, {
346 operation: "create_ref",
347 label: options?.label ?? "Creating branch",
348 client,
349 execute: (resolved) => resolved.createRef({ cwd: target.cwd!, ...input }),
350 });
351 }
352
353 async function createWorktree(
354 target: VcsActionTarget,
355 input: Omit<VcsCreateWorktreeInput, "cwd">,
356 client?: VcsActionClient,
357 options?: { readonly label?: string },
358 ): Promise<VcsCreateWorktreeResult | null> {
359 return runOperation(target, {
360 operation: "create_worktree",
361 label: options?.label ?? "Creating worktree",
362 client,
363 execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }),
364 });
365 }
366
367 async function init(
368 target: VcsActionTarget,
369 client?: VcsActionClient,
370 options?: { readonly label?: string },
371 ): Promise<Awaited<ReturnType<VcsActionClient["init"]>> | null> {
372 return runOperation(target, {
373 operation: "init",
374 label: options?.label ?? "Initializing repository",
375 client,
376 execute: (resolved) => resolved.init({ cwd: target.cwd! }),
377 });
378 }
379
380 async function runChangeRequest(
381 target: VcsActionTarget,
382 input: Omit<GitRunStackedActionInput, "cwd" | "actionId"> & { readonly actionId?: string },
383 options?: {
384 readonly client?: VcsActionClient;
385 readonly gitStatus?: VcsStatusResult | null;
386 readonly onProgress?: (event: GitActionProgressEvent) => void;
387 },
388 ): Promise<GitRunStackedActionResult | null> {
389 const actionId =
390 input.actionId ??
391 config.getActionId?.() ??
392 `vcs-action-${nowMs()}-${++nextGeneratedActionId}`;
393 const targetKey = getVcsActionTargetKey(target);
394
395 return runOperation(target, {
396 operation: "run_change_request",
397 label:
398 buildGitActionProgressStages({
399 action: input.action,
400 hasCustomCommitMessage: Boolean(input.commitMessage?.trim()),
401 hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false,
402 featureBranch: input.featureBranch ?? false,
403 shouldPushBeforePr:
404 input.action === "create_pr" &&
405 (!(options?.gitStatus?.hasUpstream ?? false) ||
406 (options?.gitStatus?.aheadCount ?? 0) > 0),
407 })[0] ?? "Running source control action",
408 actionId,
409 action: input.action,
410 client: options?.client,
411 execute: async (resolved) => {
412 const result = await resolved.runChangeRequest(
413 {
414 cwd: target.cwd!,
415 actionId,
416 ...input,
417 },
418 {
419 onProgress: (event) => {
420 if (targetKey !== null) {
421 const current = getSnapshot(target);
422 setState(targetKey, applyVcsActionProgressEvent(current, event));
423 }
424 options?.onProgress?.(event);
425 },
426 },
427 );
428 return result;
429 },
430 });
431 }
432
433 function reset(target?: VcsActionTarget): void {
434 if (target) {
435 const targetKey = getVcsActionTargetKey(target);
436 if (targetKey !== null) {
437 setState(targetKey, EMPTY_VCS_ACTION_STATE);
438 }
439 return;
440 }
441
442 for (const key of knownVcsActionKeys) {
443 setState(key, EMPTY_VCS_ACTION_STATE);
444 }
445 }
446
447 return {
448 getSnapshot,
449 refreshStatus,
450 pull,
451 switchRef,
452 createRef,
453 createWorktree,
454 init,
455 runChangeRequest,
456 reset,
457 };
458}
packages/client-runtime/src/vcsRefState.test.ts created+399
...@@ -0,0 +1,399 @@
1import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vitest";
4
5import {
6 createVcsRefManager,
7 EMPTY_VCS_REF_STATE,
8 vcsRefStateAtom,
9 type VcsRefClient,
10} from "./vcsRefState.ts";
11
12let atomRegistry = AtomRegistry.make();
13
14function resetAtomRegistry() {
15 atomRegistry.dispose();
16 atomRegistry = AtomRegistry.make();
17}
18
19const noop = () => undefined;
20
21const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
22
23const FIRST_PAGE: VcsListRefsResult = {
24 refs: [
25 { name: "main", current: true, isDefault: true, worktreePath: null },
26 { name: "feature/a", current: false, isDefault: false, worktreePath: null },
27 ],
28 isRepo: true,
29 hasPrimaryRemote: true,
30 nextCursor: 2,
31 totalCount: 3,
32};
33
34const SECOND_PAGE: VcsListRefsResult = {
35 refs: [{ name: "feature/b", current: false, isDefault: false, worktreePath: null }],
36 isRepo: true,
37 hasPrimaryRemote: true,
38 nextCursor: null,
39 totalCount: 3,
40};
41
42function createMockClient() {
43 const listRefs = vi.fn(async (input: Parameters<VcsRefClient["listRefs"]>[0]) => {
44 if (input.query === "feature") {
45 return {
46 ...FIRST_PAGE,
47 refs: FIRST_PAGE.refs.filter((branch) => branch.name.includes("feature")),
48 nextCursor: null,
49 totalCount: 2,
50 } satisfies VcsListRefsResult;
51 }
52
53 if (input.cursor === 2) {
54 return SECOND_PAGE;
55 }
56
57 return FIRST_PAGE;
58 });
59
60 return {
61 client: { listRefs } satisfies VcsRefClient,
62 listRefs,
63 };
64}
65
66function deferred<T>() {
67 let resolve!: (value: T) => void;
68 let reject!: (error: unknown) => void;
69 const promise = new Promise<T>((resolvePromise, rejectPromise) => {
70 resolve = resolvePromise;
71 reject = rejectPromise;
72 });
73 return { promise, resolve, reject };
74}
75
76describe("createVcsRefManager", () => {
77 afterEach(() => {
78 resetAtomRegistry();
79 });
80
81 it("loads the first page and stores it in atom state", async () => {
82 const mock = createMockClient();
83 const manager = createVcsRefManager({
84 getRegistry: () => atomRegistry,
85 getClient: () => mock.client,
86 });
87
88 const promise = manager.load(TARGET, mock.client, { limit: 100 });
89
90 expect(manager.getSnapshot(TARGET)).toEqual({
91 data: null,
92 isPending: true,
93 error: null,
94 });
95
96 await expect(promise).resolves.toEqual(FIRST_PAGE);
97 expect(manager.getSnapshot(TARGET)).toEqual({
98 data: FIRST_PAGE,
99 isPending: false,
100 error: null,
101 });
102 expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 });
103 });
104
105 it("loads the next page and appends refs", async () => {
106 const mock = createMockClient();
107 const manager = createVcsRefManager({
108 getRegistry: () => atomRegistry,
109 getClient: () => mock.client,
110 });
111
112 await manager.load(TARGET, mock.client);
113 const next = await manager.loadNext(TARGET, mock.client);
114
115 expect(next).toEqual({
116 ...SECOND_PAGE,
117 refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs],
118 });
119 expect(manager.getSnapshot(TARGET)).toEqual({
120 data: {
121 ...SECOND_PAGE,
122 refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs],
123 },
124 isPending: false,
125 error: null,
126 });
127 });
128
129 it("keeps cached refs visible while refreshing", async () => {
130 const nextLoad = deferred<VcsListRefsResult>();
131 let callCount = 0;
132 const listRefs = vi.fn((async () => {
133 callCount += 1;
134 return callCount === 1 ? FIRST_PAGE : nextLoad.promise;
135 }) satisfies VcsRefClient["listRefs"]);
136 const client = { listRefs } satisfies VcsRefClient;
137 const manager = createVcsRefManager({
138 getRegistry: () => atomRegistry,
139 getClient: () => client,
140 });
141
142 await manager.load(TARGET, client);
143
144 const refresh = manager.load(TARGET, client);
145 expect(manager.getSnapshot(TARGET)).toEqual({
146 data: FIRST_PAGE,
147 isPending: true,
148 error: null,
149 });
150
151 nextLoad.resolve(SECOND_PAGE);
152 await expect(refresh).resolves.toEqual(SECOND_PAGE);
153 expect(manager.getSnapshot(TARGET)).toEqual({
154 data: SECOND_PAGE,
155 isPending: false,
156 error: null,
157 });
158 });
159
160 it("preserves loaded pages during first-page revalidation", async () => {
161 const refreshedFirstPage: VcsListRefsResult = {
162 ...FIRST_PAGE,
163 refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
164 nextCursor: 1,
165 totalCount: 3,
166 };
167 let callCount = 0;
168 const listRefs = vi.fn((async (input) => {
169 callCount += 1;
170 if (input.cursor === 2) {
171 return SECOND_PAGE;
172 }
173 return callCount === 1 ? FIRST_PAGE : refreshedFirstPage;
174 }) satisfies VcsRefClient["listRefs"]);
175 const client = { listRefs } satisfies VcsRefClient;
176 const manager = createVcsRefManager({
177 getRegistry: () => atomRegistry,
178 getClient: () => client,
179 });
180
181 await manager.load(TARGET, client);
182 await manager.loadNext(TARGET, client);
183 const beforeRefresh = manager.getSnapshot(TARGET).data;
184 expect(beforeRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]);
185
186 await manager.load(TARGET, client, { preserveLoadedRefs: true });
187
188 const afterRefresh = manager.getSnapshot(TARGET).data;
189 expect(afterRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]);
190 expect(afterRefresh?.nextCursor).toBeNull();
191 });
192
193 it("stores query-specific state independently", async () => {
194 const mock = createMockClient();
195 const manager = createVcsRefManager({
196 getRegistry: () => atomRegistry,
197 getClient: () => mock.client,
198 });
199
200 const queriedTarget = { ...TARGET, query: "feature" } as const;
201 const queried = await manager.load(queriedTarget, mock.client);
202
203 expect(queried?.refs.map((branch) => branch.name)).toEqual(["feature/a"]);
204 expect(manager.getSnapshot(TARGET).data).toBeNull();
205 expect(manager.getSnapshot(queriedTarget).data?.refs.map((branch) => branch.name)).toEqual([
206 "feature/a",
207 ]);
208 });
209
210 it("returns cached data when no client is available", async () => {
211 const manager = createVcsRefManager({
212 getRegistry: () => atomRegistry,
213 getClient: () => null,
214 });
215
216 atomRegistry.set(vcsRefStateAtom("env-local:/repo:"), {
217 data: FIRST_PAGE,
218 isPending: false,
219 error: null,
220 });
221
222 await expect(manager.load(TARGET)).resolves.toEqual(FIRST_PAGE);
223 });
224
225 it("resets state", async () => {
226 const mock = createMockClient();
227 const manager = createVcsRefManager({
228 getRegistry: () => atomRegistry,
229 getClient: () => mock.client,
230 });
231
232 await manager.load(TARGET, mock.client);
233 manager.reset();
234
235 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE);
236 });
237
238 it("invalidates every query for a cwd scope", async () => {
239 const mock = createMockClient();
240 const manager = createVcsRefManager({
241 getRegistry: () => atomRegistry,
242 getClient: () => mock.client,
243 });
244 const queriedTarget = { ...TARGET, query: "feature" } as const;
245
246 await manager.load(TARGET, mock.client);
247 await manager.load(queriedTarget, mock.client);
248
249 manager.invalidateScope({ environmentId: TARGET.environmentId, cwd: TARGET.cwd });
250
251 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE);
252 expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE);
253 });
254
255 it("invalidates target in-flight loads before they can write stale data", async () => {
256 const firstLoad = deferred<VcsListRefsResult>();
257 let callCount = 0;
258 const listRefs = vi.fn((async () => {
259 callCount += 1;
260 return callCount === 1 ? firstLoad.promise : SECOND_PAGE;
261 }) satisfies VcsRefClient["listRefs"]);
262 const client = { listRefs } satisfies VcsRefClient;
263 const manager = createVcsRefManager({
264 getRegistry: () => atomRegistry,
265 getClient: () => client,
266 });
267
268 const staleLoad = manager.load(TARGET, client);
269 manager.invalidate(TARGET);
270 const freshLoad = manager.load(TARGET, client);
271
272 expect(listRefs).toHaveBeenCalledTimes(2);
273
274 firstLoad.resolve(FIRST_PAGE);
275 await expect(staleLoad).resolves.toEqual(FIRST_PAGE);
276 await expect(freshLoad).resolves.toEqual(SECOND_PAGE);
277 expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE);
278 });
279
280 it("watches refs with a ref-counted client-change subscription", async () => {
281 const mock = createMockClient();
282 let listener: () => void = noop;
283 const unsubscribe = vi.fn();
284 const manager = createVcsRefManager({
285 getRegistry: () => atomRegistry,
286 getClient: () => mock.client,
287 subscribeClientChanges: (nextListener) => {
288 listener = nextListener;
289 return unsubscribe;
290 },
291 watchLimit: 100,
292 });
293
294 const firstUnwatch = manager.watch(TARGET);
295 const secondUnwatch = manager.watch(TARGET);
296 await Promise.resolve();
297
298 expect(mock.listRefs).toHaveBeenCalledTimes(1);
299 expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 });
300
301 listener();
302 await Promise.resolve();
303 expect(mock.listRefs).toHaveBeenCalledTimes(1);
304
305 firstUnwatch();
306 expect(unsubscribe).not.toHaveBeenCalled();
307 secondUnwatch();
308 expect(unsubscribe).toHaveBeenCalledTimes(1);
309 });
310
311 it("skips watched refresh while cached refs are fresh", async () => {
312 const mock = createMockClient();
313 const manager = createVcsRefManager({
314 getRegistry: () => atomRegistry,
315 getClient: () => mock.client,
316 staleTimeMs: 60_000,
317 watchLimit: 100,
318 });
319
320 const firstUnwatch = manager.watch(TARGET);
321 await vi.waitFor(() => {
322 expect(manager.getSnapshot(TARGET).data).toEqual(FIRST_PAGE);
323 });
324 firstUnwatch();
325
326 const secondUnwatch = manager.watch(TARGET);
327 await Promise.resolve();
328 expect(mock.listRefs).toHaveBeenCalledTimes(1);
329 expect(manager.getSnapshot(TARGET)).toEqual({
330 data: FIRST_PAGE,
331 isPending: false,
332 error: null,
333 });
334
335 secondUnwatch();
336 });
337
338 it("swallows watched refresh failures after storing error state", async () => {
339 const refreshError = new Error("backend unavailable");
340 const listRefs = vi.fn(async () => {
341 throw refreshError;
342 });
343 const onBackgroundError = vi.fn();
344 const manager = createVcsRefManager({
345 getRegistry: () => atomRegistry,
346 getClient: () => ({ listRefs }),
347 onBackgroundError,
348 });
349
350 manager.watch(TARGET);
351 await Promise.resolve();
352 await Promise.resolve();
353
354 await vi.waitFor(() => {
355 expect(manager.getSnapshot(TARGET)).toEqual({
356 data: null,
357 isPending: false,
358 error: "backend unavailable",
359 });
360 expect(onBackgroundError).toHaveBeenCalledWith(refreshError);
361 });
362 });
363
364 it("starts a new watched refresh when the client is replaced while a load is in flight", async () => {
365 const firstLoad = deferred<VcsListRefsResult>();
366 const secondLoad = deferred<VcsListRefsResult>();
367 const firstListRefs = vi.fn(() => firstLoad.promise);
368 const secondListRefs = vi.fn(() => secondLoad.promise);
369 const firstClient = { listRefs: firstListRefs } satisfies VcsRefClient;
370 const secondClient = { listRefs: secondListRefs } satisfies VcsRefClient;
371 let currentClient: VcsRefClient = firstClient;
372 let listener: () => void = noop;
373 const manager = createVcsRefManager({
374 getRegistry: () => atomRegistry,
375 getClient: () => currentClient,
376 subscribeClientChanges: (nextListener) => {
377 listener = nextListener;
378 return noop;
379 },
380 });
381
382 manager.watch(TARGET);
383 await Promise.resolve();
384 expect(firstListRefs).toHaveBeenCalledTimes(1);
385
386 currentClient = secondClient;
387 listener();
388 await Promise.resolve();
389 expect(secondListRefs).toHaveBeenCalledTimes(1);
390
391 secondLoad.resolve(SECOND_PAGE);
392 await secondLoad.promise;
393 expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE);
394
395 firstLoad.resolve(FIRST_PAGE);
396 await firstLoad.promise;
397 expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE);
398 });
399});
packages/client-runtime/src/vcsRefState.ts created+451
...@@ -0,0 +1,451 @@
1import type {
2 EnvironmentId,
3 VcsListRefsInput,
4 VcsListRefsResult,
5 VcsRef as ContractVcsRef,
6} from "@t3tools/contracts";
7import * as Clock from "effect/Clock";
8import * as Effect from "effect/Effect";
9import { Atom, type AtomRegistry, type AsyncResult } from "effect/unstable/reactivity";
10
11import type { WsRpcClient } from "./wsRpcClient.ts";
12
13export interface VcsRefTarget {
14 readonly environmentId: EnvironmentId | null;
15 readonly cwd: string | null;
16 readonly query?: string | null;
17}
18
19export interface VcsRefScope {
20 readonly environmentId: EnvironmentId | null;
21 readonly cwd: string | null;
22}
23
24export interface VcsRefState {
25 readonly data: VcsListRefsResult | null;
26 readonly isPending: boolean;
27 readonly error: string | null;
28}
29
30export type VcsRef = ContractVcsRef;
31export type VcsRefClient = Pick<WsRpcClient["vcs"], "listRefs">;
32
33export const EMPTY_VCS_REF_STATE = Object.freeze<VcsRefState>({
34 data: null,
35 isPending: false,
36 error: null,
37});
38
39const INITIAL_VCS_REF_STATE = Object.freeze<VcsRefState>({
40 data: null,
41 isPending: true,
42 error: null,
43});
44
45const knownVcsRefKeys = new Set<string>();
46
47export const vcsRefStateAtom = Atom.family((key: string) => {
48 knownVcsRefKeys.add(key);
49 return Atom.make(EMPTY_VCS_REF_STATE).pipe(Atom.keepAlive, Atom.withLabel(`vcs-refs:${key}`));
50});
51
52export const EMPTY_VCS_REF_ATOM = Atom.make(EMPTY_VCS_REF_STATE).pipe(
53 Atom.keepAlive,
54 Atom.withLabel("vcs-refs:null"),
55);
56
57function normalizeQuery(query: string | null | undefined): string {
58 return query?.trim() ?? "";
59}
60
61export function getVcsRefTargetKey(target: VcsRefTarget): string | null {
62 if (target.environmentId === null || target.cwd === null) {
63 return null;
64 }
65
66 return `${target.environmentId}:${target.cwd}:${normalizeQuery(target.query)}`;
67}
68
69function toErrorMessage(error: unknown): string {
70 return error instanceof Error ? error.message : "Failed to load refs.";
71}
72
73function mergeRefs(
74 previous: ReadonlyArray<VcsRef>,
75 next: ReadonlyArray<VcsRef>,
76): ReadonlyArray<VcsRef> {
77 const merged = new Map<string, VcsRef>();
78 for (const branch of previous) {
79 merged.set(branch.name, branch);
80 }
81 for (const branch of next) {
82 merged.set(branch.name, branch);
83 }
84 return [...merged.values()];
85}
86
87export interface VcsRefManagerConfig {
88 readonly getRegistry: () => AtomRegistry.AtomRegistry;
89 readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null;
90 readonly subscribeClientChanges?: (listener: () => void) => () => void;
91 readonly watchLimit?: number;
92 readonly staleTimeMs?: number;
93 readonly onBackgroundError?: (error: unknown) => void;
94}
95
96interface WatchedEntry {
97 refCount: number;
98 teardown: () => void;
99}
100
101const NOOP: () => void = () => undefined;
102
103export function createVcsRefManager(config: VcsRefManagerConfig) {
104 const inFlight = new Map<
105 string,
106 {
107 readonly client: VcsRefClient;
108 readonly promise: Promise<VcsListRefsResult | null>;
109 }
110 >();
111 const loadVersions = new Map<string, number>();
112 const watched = new Map<string, WatchedEntry>();
113 const lastLoadedAt = new Map<string, number>();
114 const refreshTargets = new Map<string, VcsRefTarget>();
115 const watchLoadOptions =
116 config.watchLimit === undefined
117 ? undefined
118 : { limit: config.watchLimit, preserveLoadedRefs: true };
119
120 const watchedRefreshAtom = Atom.family((targetKey: string) =>
121 Atom.make(() =>
122 Effect.promise(() => {
123 const target = refreshTargets.get(targetKey);
124 return target ? load(target, undefined, watchLoadOptions) : Promise.resolve(null);
125 }),
126 ).pipe(
127 Atom.swr({
128 staleTime: config.staleTimeMs ?? 0,
129 revalidateOnMount: true,
130 }),
131 Atom.withLabel(`vcs-refs:watched-refresh:${targetKey}`),
132 ),
133 );
134
135 function getLoadVersion(targetKey: string): number {
136 return loadVersions.get(targetKey) ?? 0;
137 }
138
139 function bumpLoadVersion(targetKey: string): number {
140 const next = getLoadVersion(targetKey) + 1;
141 loadVersions.set(targetKey, next);
142 return next;
143 }
144
145 function getSnapshot(target: VcsRefTarget): VcsRefState {
146 const targetKey = getVcsRefTargetKey(target);
147 if (targetKey === null) {
148 return EMPTY_VCS_REF_STATE;
149 }
150 return config.getRegistry().get(vcsRefStateAtom(targetKey));
151 }
152
153 function setState(targetKey: string, nextState: VcsRefState): void {
154 config.getRegistry().set(vcsRefStateAtom(targetKey), nextState);
155 }
156
157 function markPending(targetKey: string): void {
158 const current = config.getRegistry().get(vcsRefStateAtom(targetKey));
159 setState(
160 targetKey,
161 current.data === null ? INITIAL_VCS_REF_STATE : { ...current, isPending: true, error: null },
162 );
163 }
164
165 function setData(targetKey: string, data: VcsListRefsResult): void {
166 lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis));
167 setState(targetKey, {
168 data,
169 isPending: false,
170 error: null,
171 });
172 }
173
174 function setError(targetKey: string, error: unknown): void {
175 const current = config.getRegistry().get(vcsRefStateAtom(targetKey));
176 setState(targetKey, {
177 data: current.data,
178 isPending: false,
179 error: toErrorMessage(error),
180 });
181 }
182
183 async function load(
184 target: VcsRefTarget,
185 client?: VcsRefClient,
186 options?: {
187 readonly cursor?: number;
188 readonly limit?: number;
189 readonly append?: boolean;
190 readonly preserveLoadedRefs?: boolean;
191 },
192 ): Promise<VcsListRefsResult | null> {
193 const targetKey = getVcsRefTargetKey(target);
194 if (targetKey === null || target.environmentId === null || target.cwd === null) {
195 return null;
196 }
197 refreshTargets.set(targetKey, target);
198
199 const resolved = client ?? config.getClient(target.environmentId);
200 if (!resolved) {
201 return getSnapshot(target).data;
202 }
203
204 const inFlightKey = `${targetKey}:${options?.cursor ?? "start"}:${options?.append ? "append" : "replace"}`;
205 const existing = inFlight.get(inFlightKey);
206 if (existing && existing.client === resolved) {
207 return existing.promise;
208 }
209
210 markPending(targetKey);
211 const loadVersion = bumpLoadVersion(targetKey);
212
213 const current = getSnapshot(target).data;
214 const request: VcsListRefsInput = {
215 cwd: target.cwd,
216 ...(normalizeQuery(target.query).length > 0 ? { query: normalizeQuery(target.query) } : {}),
217 ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}),
218 ...(options?.limit !== undefined ? { limit: options.limit } : {}),
219 };
220
221 const promise = resolved.listRefs(request).then(
222 (result) => {
223 const nextData =
224 options?.append && current
225 ? {
226 ...result,
227 refs: mergeRefs(current.refs, result.refs),
228 }
229 : options?.preserveLoadedRefs && current && current.refs.length > result.refs.length
230 ? {
231 ...result,
232 refs: mergeRefs(result.refs, current.refs),
233 nextCursor: current.nextCursor,
234 totalCount: Math.max(result.totalCount, current.totalCount),
235 }
236 : result;
237 if (getLoadVersion(targetKey) === loadVersion) {
238 setData(targetKey, nextData);
239 }
240 return nextData;
241 },
242 (error) => {
243 if (getLoadVersion(targetKey) === loadVersion) {
244 setError(targetKey, error);
245 }
246 throw error;
247 },
248 );
249
250 inFlight.set(inFlightKey, { client: resolved, promise });
251 try {
252 return await promise;
253 } finally {
254 if (inFlight.get(inFlightKey)?.promise === promise) {
255 inFlight.delete(inFlightKey);
256 }
257 }
258 }
259
260 function loadInBackground(
261 target: VcsRefTarget,
262 client: VcsRefClient,
263 options?: {
264 readonly cursor?: number;
265 readonly limit?: number;
266 readonly append?: boolean;
267 readonly preserveLoadedRefs?: boolean;
268 },
269 ): void {
270 void load(target, client, options).catch((error: unknown) => {
271 config.onBackgroundError?.(error);
272 });
273 }
274
275 async function loadNext(
276 target: VcsRefTarget,
277 client?: VcsRefClient,
278 options?: { readonly limit?: number },
279 ): Promise<VcsListRefsResult | null> {
280 const current = getSnapshot(target).data;
281 if (!current?.nextCursor && current?.nextCursor !== 0) {
282 return current ?? null;
283 }
284
285 return load(target, client, {
286 cursor: current.nextCursor,
287 append: true,
288 ...(options?.limit !== undefined ? { limit: options.limit } : {}),
289 });
290 }
291
292 function refreshWatchedTarget(targetKey: string, target: VcsRefTarget, client?: VcsRefClient) {
293 refreshTargets.set(targetKey, target);
294
295 if (client || config.staleTimeMs === undefined) {
296 const resolved =
297 client ?? (target.environmentId ? config.getClient(target.environmentId) : null);
298 if (resolved) {
299 loadInBackground(target, resolved, watchLoadOptions);
300 }
301 return;
302 }
303
304 const lastLoaded = lastLoadedAt.get(targetKey);
305 if (
306 lastLoaded !== undefined &&
307 Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs
308 ) {
309 return;
310 }
311
312 const result = config
313 .getRegistry()
314 .get(watchedRefreshAtom(targetKey)) as AsyncResult.AsyncResult<
315 VcsListRefsResult | null,
316 unknown
317 >;
318 if (result._tag === "Failure") {
319 config.onBackgroundError?.(result.cause);
320 }
321 }
322
323 function watch(target: VcsRefTarget, client?: VcsRefClient): () => void {
324 const targetKey = getVcsRefTargetKey(target);
325 if (targetKey === null || target.environmentId === null || target.cwd === null) {
326 return NOOP;
327 }
328
329 const existing = watched.get(targetKey);
330 if (existing) {
331 existing.refCount += 1;
332 return () => unwatch(targetKey);
333 }
334
335 let teardown: () => void;
336
337 if (client) {
338 refreshWatchedTarget(targetKey, target, client);
339 teardown = NOOP;
340 } else if (config.subscribeClientChanges) {
341 let currentClient: VcsRefClient | null = null;
342 const sync = () => {
343 const resolved = config.getClient(target.environmentId!);
344 if (!resolved) {
345 currentClient = null;
346 return;
347 }
348 if (currentClient === resolved) {
349 return;
350 }
351
352 const hadClient = currentClient !== null;
353 currentClient = resolved;
354 refreshWatchedTarget(targetKey, target, hadClient ? resolved : undefined);
355 };
356
357 const unsubscribe = config.subscribeClientChanges(sync);
358 sync();
359 teardown = unsubscribe;
360 } else {
361 const resolved = config.getClient(target.environmentId);
362 if (!resolved) {
363 return NOOP;
364 }
365 refreshWatchedTarget(targetKey, target);
366 teardown = NOOP;
367 }
368
369 watched.set(targetKey, { refCount: 1, teardown });
370 return () => unwatch(targetKey);
371 }
372
373 function unwatch(targetKey: string): void {
374 const entry = watched.get(targetKey);
375 if (!entry) {
376 return;
377 }
378
379 entry.refCount -= 1;
380 if (entry.refCount > 0) {
381 return;
382 }
383
384 entry.teardown();
385 watched.delete(targetKey);
386 }
387
388 function invalidate(target?: VcsRefTarget): void {
389 if (target) {
390 const targetKey = getVcsRefTargetKey(target);
391 if (targetKey !== null) {
392 bumpLoadVersion(targetKey);
393 setState(targetKey, EMPTY_VCS_REF_STATE);
394 for (const key of inFlight.keys()) {
395 if (key.startsWith(`${targetKey}:`)) {
396 inFlight.delete(key);
397 }
398 }
399 }
400 return;
401 }
402
403 for (const key of knownVcsRefKeys) {
404 bumpLoadVersion(key);
405 setState(key, EMPTY_VCS_REF_STATE);
406 }
407 inFlight.clear();
408 }
409
410 function invalidateScope(scope: VcsRefScope): void {
411 if (scope.environmentId === null || scope.cwd === null) {
412 return;
413 }
414
415 const keyPrefix = `${scope.environmentId}:${scope.cwd}:`;
416 for (const key of knownVcsRefKeys) {
417 if (key.startsWith(keyPrefix)) {
418 bumpLoadVersion(key);
419 setState(key, EMPTY_VCS_REF_STATE);
420 }
421 }
422
423 for (const key of inFlight.keys()) {
424 if (key.startsWith(keyPrefix)) {
425 inFlight.delete(key);
426 }
427 }
428 }
429
430 function reset(): void {
431 for (const entry of watched.values()) {
432 entry.teardown();
433 }
434 watched.clear();
435 inFlight.clear();
436 loadVersions.clear();
437 lastLoadedAt.clear();
438 refreshTargets.clear();
439 invalidate();
440 }
441
442 return {
443 getSnapshot,
444 watch,
445 load,
446 loadNext,
447 invalidate,
448 invalidateScope,
449 reset,
450 };
451}
packages/client-runtime/src/vcsStatusState.test.ts created+330
...@@ -0,0 +1,330 @@
1import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vitest";
4
5import { type VcsStatusClient, createVcsStatusManager } from "./vcsStatusState.ts";
6
7/* ─── Test helpers ──────────────────────────────────────────────────── */
8
9let atomRegistry = AtomRegistry.make();
10
11function resetAtomRegistry() {
12 atomRegistry.dispose();
13 atomRegistry = AtomRegistry.make();
14}
15
16function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
17 listeners.add(listener);
18 return () => {
19 listeners.delete(listener);
20 };
21}
22
23const BASE_STATUS: VcsStatusResult = {
24 isRepo: true,
25 hasPrimaryRemote: true,
26 isDefaultRef: false,
27 refName: "feature/push-status",
28 hasWorkingTreeChanges: false,
29 workingTree: { files: [], insertions: 0, deletions: 0 },
30 hasUpstream: true,
31 aheadCount: 0,
32 behindCount: 0,
33 pr: null,
34};
35
36function createMockClient(): {
37 client: VcsStatusClient;
38 listeners: Set<(event: VcsStatusResult) => void>;
39 emit: (event: VcsStatusResult) => void;
40} {
41 const listeners = new Set<(event: VcsStatusResult) => void>();
42 const client: VcsStatusClient = {
43 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
44 ...BASE_STATUS,
45 refName: `${input.cwd}-refreshed`,
46 })),
47 onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
48 registerListener(listeners, listener),
49 ),
50 };
51 return {
52 client,
53 listeners,
54 emit: (event) => {
55 for (const listener of listeners) listener(event);
56 },
57 };
58}
59
60const PENDING = { data: null, error: null, cause: null, isPending: true };
61const EMPTY = { data: null, error: null, cause: null, isPending: false };
62
63const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
64const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const;
65const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const;
66
67/* ─── Tests ─────────────────────────────────────────────────────────── */
68
69describe("createVcsStatusManager", () => {
70 afterEach(() => {
71 resetAtomRegistry();
72 });
73
74 describe("with explicit client (no reconnection)", () => {
75 it("starts in a pending state when watching", () => {
76 const { client } = createMockClient();
77 const manager = createVcsStatusManager({
78 getRegistry: () => atomRegistry,
79 getClient: () => null,
80 });
81
82 manager.watch(TARGET, client);
83 expect(manager.getSnapshot(TARGET)).toEqual(PENDING);
84 manager.reset();
85 });
86
87 it("shares one subscription per cwd and updates the snapshot", () => {
88 const { client, listeners, emit } = createMockClient();
89 const manager = createVcsStatusManager({
90 getRegistry: () => atomRegistry,
91 getClient: () => null,
92 });
93
94 const releaseA = manager.watch(TARGET, client);
95 const releaseB = manager.watch(TARGET, client);
96
97 expect(client.onStatus).toHaveBeenCalledOnce();
98 expect(manager.getSnapshot(TARGET)).toEqual(PENDING);
99
100 emit(BASE_STATUS);
101 expect(manager.getSnapshot(TARGET)).toEqual({
102 data: BASE_STATUS,
103 error: null,
104 cause: null,
105 isPending: false,
106 });
107
108 releaseA();
109 expect(listeners.size).toBe(1);
110
111 releaseB();
112 expect(listeners.size).toBe(0);
113 });
114
115 it("refreshes via unary RPC without restarting the stream", async () => {
116 const { client, emit } = createMockClient();
117 const manager = createVcsStatusManager({
118 getRegistry: () => atomRegistry,
119 getClient: () => null,
120 });
121
122 const release = manager.watch(TARGET, client);
123 emit(BASE_STATUS);
124
125 const refreshed = await manager.refresh(TARGET, client);
126
127 expect(client.onStatus).toHaveBeenCalledOnce();
128 expect(client.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
129 expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" });
130
131 // Snapshot still reflects stream data, not the refresh response
132 expect(manager.getSnapshot(TARGET)).toEqual({
133 data: BASE_STATUS,
134 error: null,
135 cause: null,
136 isPending: false,
137 });
138
139 release();
140 });
141
142 it("keeps subscriptions isolated by environment when cwds match", () => {
143 const local = createMockClient();
144 const remote = createMockClient();
145 const manager = createVcsStatusManager({
146 getRegistry: () => atomRegistry,
147 getClient: () => null,
148 });
149
150 const releaseLocal = manager.watch(TARGET, local.client);
151 const releaseRemote = manager.watch(OTHER_ENV_TARGET, remote.client);
152
153 local.emit(BASE_STATUS);
154 remote.emit({ ...BASE_STATUS, refName: "remote-branch" });
155
156 expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status");
157 expect(manager.getSnapshot(OTHER_ENV_TARGET).data?.refName).toBe("remote-branch");
158
159 releaseLocal();
160 releaseRemote();
161 });
162
163 it("returns null from refresh when no client is available", async () => {
164 const manager = createVcsStatusManager({
165 getRegistry: () => atomRegistry,
166 getClient: () => null,
167 });
168
169 await expect(manager.refresh(TARGET)).resolves.toBeNull();
170 });
171
172 it("returns empty state for null targets", () => {
173 const manager = createVcsStatusManager({
174 getRegistry: () => atomRegistry,
175 getClient: () => null,
176 });
177
178 expect(manager.getSnapshot({ environmentId: null, cwd: null })).toEqual(EMPTY);
179 });
180 });
181
182 describe("with subscribeClientChanges (reconnection)", () => {
183 it("waits for a delayed client registration", () => {
184 const connectionListeners = new Set<() => void>();
185 const clients = new Map<string, ReturnType<typeof createMockClient>>();
186
187 const manager = createVcsStatusManager({
188 getRegistry: () => atomRegistry,
189 getClient: (envId) => clients.get(envId)?.client ?? null,
190 getClientIdentity: (envId) => (clients.has(envId) ? envId : null),
191 subscribeClientChanges: (listener) => {
192 connectionListeners.add(listener);
193 return () => connectionListeners.delete(listener);
194 },
195 });
196
197 const release = manager.watch(TARGET);
198 expect(manager.getSnapshot(TARGET)).toEqual(PENDING);
199
200 // Register the client
201 const mock = createMockClient();
202 clients.set("env-local", mock);
203 for (const listener of connectionListeners) listener();
204
205 mock.emit(BASE_STATUS);
206 expect(manager.getSnapshot(TARGET)).toEqual({
207 data: BASE_STATUS,
208 error: null,
209 cause: null,
210 isPending: false,
211 });
212
213 release();
214 });
215
216 it("resubscribes after client is removed and re-registered", () => {
217 const connectionListeners = new Set<() => void>();
218 const clients = new Map<string, ReturnType<typeof createMockClient>>();
219
220 const manager = createVcsStatusManager({
221 getRegistry: () => atomRegistry,
222 getClient: (envId) => clients.get(envId)?.client ?? null,
223 getClientIdentity: (envId) =>
224 clients.get(envId) ? `identity:${envId}:${clients.size}` : null,
225 subscribeClientChanges: (listener) => {
226 connectionListeners.add(listener);
227 return () => connectionListeners.delete(listener);
228 },
229 });
230
231 // Register first client and watch
232 const first = createMockClient();
233 clients.set("env-local", first);
234 const release = manager.watch(TARGET);
235
236 first.emit(BASE_STATUS);
237 expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status");
238
239 // Remove client
240 clients.delete("env-local");
241 for (const listener of connectionListeners) listener();
242
243 expect(manager.getSnapshot(TARGET)).toEqual({
244 data: BASE_STATUS,
245 error: null,
246 cause: null,
247 isPending: true,
248 });
249
250 // Register new client (different identity)
251 const second = createMockClient();
252 clients.set("env-local", second);
253 for (const listener of connectionListeners) listener();
254
255 second.emit({ ...BASE_STATUS, refName: "reconnected-branch" });
256 expect(manager.getSnapshot(TARGET).data?.refName).toBe("reconnected-branch");
257
258 release();
259 });
260
261 it("cleans up connection listener on unwatch", () => {
262 const connectionListeners = new Set<() => void>();
263 const mock = createMockClient();
264
265 const manager = createVcsStatusManager({
266 getRegistry: () => atomRegistry,
267 getClient: () => mock.client,
268 getClientIdentity: () => "id",
269 subscribeClientChanges: (listener) => {
270 connectionListeners.add(listener);
271 return () => connectionListeners.delete(listener);
272 },
273 });
274
275 const release = manager.watch(TARGET);
276 expect(connectionListeners.size).toBe(1);
277
278 release();
279 expect(connectionListeners.size).toBe(0);
280 expect(mock.listeners.size).toBe(0);
281 });
282 });
283
284 describe("with getClient config (one-shot)", () => {
285 it("resolves client from config and subscribes", () => {
286 const mock = createMockClient();
287 const manager = createVcsStatusManager({
288 getRegistry: () => atomRegistry,
289 getClient: (envId) => (envId === "env-local" ? mock.client : null),
290 });
291
292 const release = manager.watch(TARGET);
293 expect(mock.client.onStatus).toHaveBeenCalledOnce();
294
295 mock.emit(BASE_STATUS);
296 expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status");
297
298 release();
299 expect(mock.listeners.size).toBe(0);
300 });
301
302 it("returns noop when client is not available", () => {
303 const manager = createVcsStatusManager({
304 getRegistry: () => atomRegistry,
305 getClient: () => null,
306 });
307
308 const release = manager.watch(TARGET);
309 expect(manager.getSnapshot(TARGET)).toEqual(PENDING);
310 release(); // should not throw
311 });
312 });
313
314 describe("reset", () => {
315 it("tears down all active subscriptions", () => {
316 const mock = createMockClient();
317 const manager = createVcsStatusManager({
318 getRegistry: () => atomRegistry,
319 getClient: () => mock.client,
320 });
321
322 manager.watch(TARGET);
323 manager.watch(FRESH_TARGET);
324 expect(mock.listeners.size).toBe(2);
325
326 manager.reset();
327 expect(mock.listeners.size).toBe(0);
328 });
329 });
330});
packages/client-runtime/src/vcsStatusState.ts created+292
...@@ -0,0 +1,292 @@
1import type { EnvironmentId, GitManagerServiceError, VcsStatusResult } from "@t3tools/contracts";
2import type * as Cause from "effect/Cause";
3import * as DateTime from "effect/DateTime";
4import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
5import type { WsRpcClient } from "./wsRpcClient.ts";
6
7/* ─── Types ─────────────────────────────────────────────────────────── */
8
9export interface VcsStatusState {
10 readonly data: VcsStatusResult | null;
11 readonly error: GitManagerServiceError | null;
12 readonly cause: Cause.Cause<GitManagerServiceError> | null;
13 readonly isPending: boolean;
14}
15
16export interface VcsStatusTarget {
17 readonly environmentId: EnvironmentId | null;
18 readonly cwd: string | null;
19}
20
21export type VcsStatusClient = Pick<WsRpcClient["vcs"], "onStatus" | "refreshStatus">;
22
23interface WatchedEntry {
24 refCount: number;
25 teardown: () => void;
26}
27
28/* ─── Constants ─────────────────────────────────────────────────────── */
29
30const NOOP: () => void = () => undefined;
31
32export const EMPTY_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({
33 data: null,
34 error: null,
35 cause: null,
36 isPending: false,
37});
38
39const INITIAL_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({
40 data: null,
41 error: null,
42 cause: null,
43 isPending: true,
44});
45
46/* ─── Atoms ─────────────────────────────────────────────────────────── */
47
48const knownVcsStatusKeys = new Set<string>();
49
50export const vcsStatusStateAtom = Atom.family((key: string) => {
51 knownVcsStatusKeys.add(key);
52 return Atom.make(INITIAL_VCS_STATUS_STATE).pipe(
53 Atom.keepAlive,
54 Atom.withLabel(`vcs-status:${key}`),
55 );
56});
57
58export const EMPTY_VCS_STATUS_ATOM = Atom.make(EMPTY_VCS_STATUS_STATE).pipe(
59 Atom.keepAlive,
60 Atom.withLabel("vcs-status:null"),
61);
62
63/* ─── Helpers ───────────────────────────────────────────────────────── */
64
65export function getVcsStatusTargetKey(target: VcsStatusTarget): string | null {
66 if (target.environmentId === null || target.cwd === null) {
67 return null;
68 }
69 return `${target.environmentId}:${target.cwd}`;
70}
71
72/* ─── Subscription manager ──────────────────────────────────────────── */
73
74export interface VcsStatusManagerConfig {
75 /**
76 * Get the atom registry to read/write VCS status atoms.
77 */
78 readonly getRegistry: () => AtomRegistry.AtomRegistry;
79 /** Resolve a VCS client for an environment. */
80 readonly getClient: (environmentId: EnvironmentId) => VcsStatusClient | null;
81 /**
82 * Optional: get a stable identity for the current client.
83 * Used to detect reconnections — when the identity changes the
84 * manager tears down the old `onStatus` stream and subscribes anew.
85 */
86 readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null;
87 /**
88 * Optional: subscribe to environment-connection changes.
89 * When provided the manager reacts to client appear / disappear /
90 * reconnect events instead of doing a one-shot resolution.
91 */
92 readonly subscribeClientChanges?: (listener: () => void) => () => void;
93}
94
95const VCS_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
96const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe());
97
98export function createVcsStatusManager(config: VcsStatusManagerConfig) {
99 const watched = new Map<string, WatchedEntry>();
100 const refreshInFlight = new Map<string, Promise<VcsStatusResult>>();
101 const lastRefreshAt = new Map<string, number>();
102
103 /* ── Atom helpers ───────────────────────────────────────────────── */
104
105 function markPending(targetKey: string): void {
106 const atom = vcsStatusStateAtom(targetKey);
107 const current = config.getRegistry().get(atom);
108 const next: VcsStatusState =
109 current.data === null
110 ? INITIAL_VCS_STATUS_STATE
111 : { ...current, error: null, cause: null, isPending: true };
112 if (
113 current.data === next.data &&
114 current.error === next.error &&
115 current.cause === next.cause &&
116 current.isPending === next.isPending
117 ) {
118 return;
119 }
120 config.getRegistry().set(atom, next);
121 }
122
123 function setData(targetKey: string, status: VcsStatusResult): void {
124 config.getRegistry().set(vcsStatusStateAtom(targetKey), {
125 data: status,
126 error: null,
127 cause: null,
128 isPending: false,
129 });
130 }
131
132 /* ── Core subscription ──────────────────────────────────────────── */
133
134 function subscribeStream(targetKey: string, cwd: string, client: VcsStatusClient): () => void {
135 markPending(targetKey);
136 return client.onStatus({ cwd }, (status) => setData(targetKey, status), {
137 onResubscribe: () => markPending(targetKey),
138 });
139 }
140
141 /* ── Dynamic subscription (handles reconnection) ────────────────── */
142
143 function createDynamicSubscription(targetKey: string, target: VcsStatusTarget): () => void {
144 const environmentId = target.environmentId!;
145 const cwd = target.cwd!;
146 let currentIdentity: string | null = null;
147 let currentUnsub = NOOP;
148
149 const sync = () => {
150 const client = config.getClient(environmentId);
151 const identity = client ? (config.getClientIdentity?.(environmentId) ?? environmentId) : null;
152
153 if (!client || identity === null) {
154 if (currentIdentity !== null) {
155 currentUnsub();
156 currentUnsub = NOOP;
157 currentIdentity = null;
158 }
159 markPending(targetKey);
160 return;
161 }
162
163 if (currentIdentity === identity) return;
164
165 currentUnsub();
166 currentIdentity = identity;
167 currentUnsub = subscribeStream(targetKey, cwd, client);
168 };
169
170 const unsubChanges = config.subscribeClientChanges!(sync);
171 sync();
172
173 return () => {
174 unsubChanges();
175 currentUnsub();
176 };
177 }
178
179 /* ── Public API ─────────────────────────────────────────────────── */
180
181 /**
182 * Begin watching VCS status for `target`.
183 *
184 * Multiple watchers sharing the same `environmentId:cwd` key share
185 * one `onStatus` WS subscription (ref-counted).
186 *
187 * @param target The environment + cwd to watch.
188 * @param client Optional pre-resolved client — skips `getClient`
189 * lookup and reconnection handling. Useful in tests.
190 * @returns An unwatch function.
191 */
192 function watch(target: VcsStatusTarget, client?: VcsStatusClient): () => void {
193 const targetKey = getVcsStatusTargetKey(target);
194 if (targetKey === null || target.environmentId === null || target.cwd === null) {
195 return NOOP;
196 }
197
198 const existing = watched.get(targetKey);
199 if (existing) {
200 existing.refCount += 1;
201 return () => unwatch(targetKey);
202 }
203
204 let teardown: () => void;
205
206 if (client) {
207 // Explicit client — direct subscription, no reconnection handling.
208 teardown = subscribeStream(targetKey, target.cwd, client);
209 } else if (config.subscribeClientChanges) {
210 // Dynamic client — subscribe to connection changes for reconnection.
211 teardown = createDynamicSubscription(targetKey, target);
212 } else {
213 // One-shot client resolution.
214 const resolved = config.getClient(target.environmentId);
215 if (!resolved) return NOOP;
216 teardown = subscribeStream(targetKey, target.cwd, resolved);
217 }
218
219 watched.set(targetKey, { refCount: 1, teardown });
220 return () => unwatch(targetKey);
221 }
222
223 function unwatch(targetKey: string): void {
224 const entry = watched.get(targetKey);
225 if (!entry) return;
226
227 entry.refCount -= 1;
228 if (entry.refCount > 0) return;
229
230 entry.teardown();
231 watched.delete(targetKey);
232 }
233
234 /**
235 * Trigger a one-shot `refreshStatus` RPC for a target.
236 * Debounced (1 s) and deduplicated (in-flight).
237 * The server-side refresh pushes a new event on the existing
238 * `onStatus` stream, so the subscription picks it up automatically.
239 */
240 function refresh(
241 target: VcsStatusTarget,
242 client?: VcsStatusClient,
243 ): Promise<VcsStatusResult | null> {
244 const targetKey = getVcsStatusTargetKey(target);
245 if (targetKey === null || target.cwd === null) {
246 return Promise.resolve(null);
247 }
248
249 const resolved =
250 client ?? (target.environmentId ? config.getClient(target.environmentId) : null);
251 if (!resolved) {
252 return Promise.resolve(getSnapshot(target).data);
253 }
254
255 const existing = refreshInFlight.get(targetKey);
256 if (existing) return existing;
257
258 const requestedAt = nowMs();
259 const last = lastRefreshAt.get(targetKey) ?? 0;
260 if (requestedAt - last < VCS_STATUS_REFRESH_DEBOUNCE_MS) {
261 return Promise.resolve(getSnapshot(target).data);
262 }
263
264 lastRefreshAt.set(targetKey, requestedAt);
265 const promise = resolved
266 .refreshStatus({ cwd: target.cwd })
267 .finally(() => refreshInFlight.delete(targetKey));
268 refreshInFlight.set(targetKey, promise);
269 return promise;
270 }
271
272 function getSnapshot(target: VcsStatusTarget): VcsStatusState {
273 const targetKey = getVcsStatusTargetKey(target);
274 if (targetKey === null) return EMPTY_VCS_STATUS_STATE;
275 return config.getRegistry().get(vcsStatusStateAtom(targetKey));
276 }
277
278 function reset(): void {
279 for (const entry of watched.values()) {
280 entry.teardown();
281 }
282 watched.clear();
283 refreshInFlight.clear();
284 lastRefreshAt.clear();
285 for (const key of knownVcsStatusKeys) {
286 config.getRegistry().set(vcsStatusStateAtom(key), INITIAL_VCS_STATUS_STATE);
287 }
288 knownVcsStatusKeys.clear();
289 }
290
291 return { watch, refresh, getSnapshot, reset };
292}
packages/client-runtime/src/wsRpcClient.test.ts created+186
...@@ -0,0 +1,186 @@
1import type {
2 VcsStatusLocalResult,
3 VcsStatusRemoteResult,
4 VcsStatusStreamEvent,
5} from "@t3tools/contracts";
6import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts";
7import { describe, expect, it, vi } from "vitest";
8
9vi.mock("./wsTransport.ts", () => ({
10 WsTransport: class WsTransport {
11 dispose = vi.fn(async () => undefined);
12 reconnect = vi.fn(async () => undefined);
13 request = vi.fn();
14 requestStream = vi.fn();
15 subscribe = vi.fn(() => () => undefined);
16 },
17}));
18
19import { createWsRpcClient } from "./wsRpcClient.ts";
20import type { WsTransport } from "./wsTransport.ts";
21
22const baseLocalStatus: VcsStatusLocalResult = {
23 isRepo: true,
24 hasPrimaryRemote: true,
25 isDefaultRef: false,
26 refName: "feature/demo",
27 hasWorkingTreeChanges: false,
28 workingTree: { files: [], insertions: 0, deletions: 0 },
29};
30
31const baseRemoteStatus: VcsStatusRemoteResult = {
32 hasUpstream: true,
33 aheadCount: 0,
34 behindCount: 0,
35 pr: null,
36};
37
38describe("createWsRpcClient", () => {
39 it("runs beforeReconnect before awaiting transport.reconnect", async () => {
40 const order: string[] = [];
41 const transport = {
42 dispose: vi.fn(async () => undefined),
43 reconnect: vi.fn(async () => {
44 order.push("reconnect");
45 }),
46 isHeartbeatFresh: vi.fn(() => true),
47 request: vi.fn(),
48 requestStream: vi.fn(),
49 subscribe: vi.fn(() => () => undefined),
50 } satisfies Pick<
51 WsTransport,
52 "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe"
53 >;
54
55 const client = createWsRpcClient(transport as unknown as WsTransport, {
56 beforeReconnect: () => {
57 order.push("beforeReconnect");
58 },
59 });
60
61 await client.reconnect();
62 expect(order).toEqual(["beforeReconnect", "reconnect"]);
63 });
64
65 it("delegates heartbeat freshness to the transport", () => {
66 const isHeartbeatFresh = vi.fn(() => true);
67 const transport = {
68 dispose: vi.fn(async () => undefined),
69 reconnect: vi.fn(async () => undefined),
70 isHeartbeatFresh,
71 request: vi.fn(),
72 requestStream: vi.fn(),
73 subscribe: vi.fn(() => () => undefined),
74 } satisfies Pick<
75 WsTransport,
76 "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe"
77 >;
78
79 const client = createWsRpcClient(transport as unknown as WsTransport);
80
81 expect(client.isHeartbeatFresh()).toBe(true);
82 expect(isHeartbeatFresh).toHaveBeenCalledOnce();
83 });
84
85 it("reduces vcs status stream events into flat status snapshots", () => {
86 const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => {
87 for (const event of [
88 {
89 _tag: "snapshot",
90 local: baseLocalStatus,
91 remote: null,
92 },
93 {
94 _tag: "remoteUpdated",
95 remote: baseRemoteStatus,
96 },
97 {
98 _tag: "localUpdated",
99 local: {
100 ...baseLocalStatus,
101 hasWorkingTreeChanges: true,
102 },
103 },
104 ] satisfies VcsStatusStreamEvent[]) {
105 listener(event as TValue);
106 }
107 return () => undefined;
108 });
109
110 const transport = {
111 dispose: vi.fn(async () => undefined),
112 reconnect: vi.fn(async () => undefined),
113 isHeartbeatFresh: vi.fn(() => true),
114 request: vi.fn(),
115 requestStream: vi.fn(),
116 subscribe,
117 } satisfies Pick<
118 WsTransport,
119 "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe"
120 >;
121
122 const client = createWsRpcClient(transport as unknown as WsTransport);
123 const listener = vi.fn();
124
125 client.vcs.onStatus({ cwd: "/repo" }, listener);
126
127 expect(listener.mock.calls).toEqual([
128 [
129 {
130 ...baseLocalStatus,
131 hasUpstream: false,
132 aheadCount: 0,
133 behindCount: 0,
134 aheadOfDefaultCount: 0,
135 pr: null,
136 },
137 ],
138 [
139 {
140 ...baseLocalStatus,
141 ...baseRemoteStatus,
142 },
143 ],
144 [
145 {
146 ...baseLocalStatus,
147 ...baseRemoteStatus,
148 hasWorkingTreeChanges: true,
149 },
150 ],
151 ]);
152 });
153
154 it("tags stream subscriptions for targeted resubscribe handling", () => {
155 const subscribe = vi.fn(() => () => undefined);
156 const transport = {
157 dispose: vi.fn(async () => undefined),
158 reconnect: vi.fn(async () => undefined),
159 isHeartbeatFresh: vi.fn(() => true),
160 request: vi.fn(),
161 requestStream: vi.fn(),
162 subscribe,
163 } satisfies Pick<
164 WsTransport,
165 "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe"
166 >;
167
168 const client = createWsRpcClient(transport as unknown as WsTransport);
169 const listener = vi.fn();
170
171 client.terminal.onMetadata(listener);
172 client.vcs.onStatus({ cwd: "/repo" }, listener);
173 client.server.subscribeConfig(listener);
174 client.orchestration.subscribeThread({ threadId: ThreadId.make("thread-1") }, listener);
175
176 const subscribeCalls = subscribe.mock.calls as unknown as Array<
177 readonly [unknown, unknown, { readonly tag?: string }?]
178 >;
179 expect(subscribeCalls.map((call) => call[2]?.tag)).toEqual([
180 WS_METHODS.subscribeTerminalMetadata,
181 WS_METHODS.subscribeVcsStatus,
182 WS_METHODS.subscribeServerConfig,
183 ORCHESTRATION_WS_METHODS.subscribeThread,
184 ]);
185 });
186});
packages/client-runtime/src/wsRpcClient.ts created+348
...@@ -0,0 +1,348 @@
1import {
2 type GitActionProgressEvent,
3 type GitRunStackedActionInput,
4 type GitRunStackedActionResult,
5 type LocalApi,
6 ORCHESTRATION_WS_METHODS,
7 type ServerSettingsPatch,
8 type VcsStatusResult,
9 type VcsStatusStreamEvent,
10 WS_METHODS,
11} from "@t3tools/contracts";
12import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
13import type * as Effect from "effect/Effect";
14import type * as Stream from "effect/Stream";
15
16import { type WsRpcProtocolClient } from "./wsRpcProtocol.ts";
17import { WsTransport } from "./wsTransport.ts";
18
19type RpcTag = keyof WsRpcProtocolClient & string;
20type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];
21type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];
22
23interface StreamSubscriptionOptions {
24 readonly onResubscribe?: () => void;
25}
26
27function subscriptionOptions(
28 options: StreamSubscriptionOptions | undefined,
29 tag: string,
30): StreamSubscriptionOptions & { readonly tag: string } {
31 return {
32 ...options,
33 tag,
34 };
35}
36
37type RpcUnaryMethod<TTag extends RpcTag> =
38 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
39 ? (input: RpcInput<TTag>) => Promise<TSuccess>
40 : never;
41
42type RpcUnaryNoArgMethod<TTag extends RpcTag> =
43 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
44 ? () => Promise<TSuccess>
45 : never;
46
47type RpcStreamMethod<TTag extends RpcTag> =
48 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any>
49 ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void
50 : never;
51
52type RpcInputStreamMethod<TTag extends RpcTag> =
53 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any>
54 ? (
55 input: RpcInput<TTag>,
56 listener: (event: TEvent) => void,
57 options?: StreamSubscriptionOptions,
58 ) => () => void
59 : never;
60
61interface GitRunStackedActionOptions {
62 readonly onProgress?: (event: GitActionProgressEvent) => void;
63}
64
65export interface WsRpcClient {
66 readonly dispose: () => Promise<void>;
67 readonly reconnect: () => Promise<void>;
68 readonly isHeartbeatFresh: () => boolean;
69 readonly terminal: {
70 readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>;
71 readonly attach: RpcInputStreamMethod<typeof WS_METHODS.terminalAttach>;
72 readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>;
73 readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>;
74 readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>;
75 readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>;
76 readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>;
77 readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>;
78 readonly onMetadata: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalMetadata>;
79 };
80 readonly projects: {
81 readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;
82 readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;
83 };
84 readonly filesystem: {
85 readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>;
86 };
87 readonly sourceControl: {
88 readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>;
89 readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>;
90 readonly publishRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlPublishRepository>;
91 };
92 readonly shell: {
93 readonly openInEditor: (input: {
94 readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0];
95 readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1];
96 }) => ReturnType<LocalApi["shell"]["openInEditor"]>;
97 };
98 readonly vcs: {
99 readonly pull: RpcUnaryMethod<typeof WS_METHODS.vcsPull>;
100 readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.vcsRefreshStatus>;
101 readonly onStatus: (
102 input: RpcInput<typeof WS_METHODS.subscribeVcsStatus>,
103 listener: (status: VcsStatusResult) => void,
104 options?: StreamSubscriptionOptions,
105 ) => () => void;
106 readonly listRefs: RpcUnaryMethod<typeof WS_METHODS.vcsListRefs>;
107 readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsCreateWorktree>;
108 readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsRemoveWorktree>;
109 readonly createRef: RpcUnaryMethod<typeof WS_METHODS.vcsCreateRef>;
110 readonly switchRef: RpcUnaryMethod<typeof WS_METHODS.vcsSwitchRef>;
111 readonly init: RpcUnaryMethod<typeof WS_METHODS.vcsInit>;
112 };
113 readonly git: {
114 readonly runStackedAction: (
115 input: GitRunStackedActionInput,
116 options?: GitRunStackedActionOptions,
117 ) => Promise<GitRunStackedActionResult>;
118 readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>;
119 readonly preparePullRequestThread: RpcUnaryMethod<
120 typeof WS_METHODS.gitPreparePullRequestThread
121 >;
122 };
123 readonly review: {
124 readonly getDiffPreview: RpcUnaryMethod<typeof WS_METHODS.reviewGetDiffPreview>;
125 };
126 readonly server: {
127 readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>;
128 readonly refreshProviders: (
129 input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>,
130 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>;
131 readonly discoverSourceControl: RpcUnaryNoArgMethod<
132 typeof WS_METHODS.serverDiscoverSourceControl
133 >;
134 readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>;
135 readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;
136 readonly removeKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverRemoveKeybinding>;
137 readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;
138 readonly updateSettings: (
139 patch: ServerSettingsPatch,
140 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>;
141 readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>;
142 readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>;
143 readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>;
144 readonly getTraceDiagnostics: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetTraceDiagnostics>;
145 readonly getProcessDiagnostics: RpcUnaryNoArgMethod<
146 typeof WS_METHODS.serverGetProcessDiagnostics
147 >;
148 readonly getProcessResourceHistory: RpcUnaryMethod<
149 typeof WS_METHODS.serverGetProcessResourceHistory
150 >;
151 readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>;
152 };
153 readonly orchestration: {
154 readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>;
155 readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>;
156 readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>;
157 readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod<
158 typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot
159 >;
160 readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>;
161 readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>;
162 };
163}
164
165export interface CreateWsRpcClientOptions {
166 /** Runs immediately before `transport.reconnect()` (e.g. reset reconnect UI/backoff state). */
167 readonly beforeReconnect?: () => void;
168}
169
170export function createWsRpcClient(
171 transport: WsTransport,
172 options?: CreateWsRpcClientOptions,
173): WsRpcClient {
174 return {
175 dispose: () => transport.dispose(),
176 isHeartbeatFresh: () => transport.isHeartbeatFresh(),
177 reconnect: async () => {
178 options?.beforeReconnect?.();
179 await transport.reconnect();
180 },
181 terminal: {
182 open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),
183 attach: (input, listener, options) =>
184 transport.subscribe(
185 (client) => client[WS_METHODS.terminalAttach](input),
186 listener,
187 subscriptionOptions(options, WS_METHODS.terminalAttach),
188 ),
189 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),
190 resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)),
191 clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)),
192 restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),
193 close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),
194 onEvent: (listener, options) =>
195 transport.subscribe(
196 (client) => client[WS_METHODS.subscribeTerminalEvents]({}),
197 listener,
198 subscriptionOptions(options, WS_METHODS.subscribeTerminalEvents),
199 ),
200 onMetadata: (listener, options) =>
201 transport.subscribe(
202 (client) => client[WS_METHODS.subscribeTerminalMetadata]({}),
203 listener,
204 subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata),
205 ),
206 },
207 projects: {
208 searchEntries: (input) =>
209 transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)),
210 writeFile: (input) =>
211 transport.request((client) => client[WS_METHODS.projectsWriteFile](input)),
212 },
213 filesystem: {
214 browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
215 },
216 sourceControl: {
217 lookupRepository: (input) =>
218 transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)),
219 cloneRepository: (input) =>
220 transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)),
221 publishRepository: (input) =>
222 transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)),
223 },
224 shell: {
225 openInEditor: (input) =>
226 transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)),
227 },
228 vcs: {
229 pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)),
230 refreshStatus: (input) =>
231 transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)),
232 onStatus: (input, listener, options) => {
233 let current: VcsStatusResult | null = null;
234 return transport.subscribe(
235 (client) => client[WS_METHODS.subscribeVcsStatus](input),
236 (event: VcsStatusStreamEvent) => {
237 current = applyGitStatusStreamEvent(current, event);
238 listener(current);
239 },
240 subscriptionOptions(options, WS_METHODS.subscribeVcsStatus),
241 );
242 },
243 listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)),
244 createWorktree: (input) =>
245 transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)),
246 removeWorktree: (input) =>
247 transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)),
248 createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)),
249 switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)),
250 init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)),
251 },
252 git: {
253 runStackedAction: async (input, options) => {
254 let result: GitRunStackedActionResult | null = null;
255
256 await transport.requestStream(
257 (client) => client[WS_METHODS.gitRunStackedAction](input),
258 (event) => {
259 options?.onProgress?.(event);
260 if (event.kind === "action_finished") {
261 result = event.result;
262 }
263 },
264 );
265
266 if (result) {
267 return result;
268 }
269
270 throw new Error("Git action stream completed without a final result.");
271 },
272 resolvePullRequest: (input) =>
273 transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)),
274 preparePullRequestThread: (input) =>
275 transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)),
276 },
277 review: {
278 getDiffPreview: (input) =>
279 transport.request((client) => client[WS_METHODS.reviewGetDiffPreview](input)),
280 },
281 server: {
282 getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
283 refreshProviders: (input) =>
284 transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})),
285 discoverSourceControl: () =>
286 transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})),
287 updateProvider: (input) =>
288 transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)),
289 upsertKeybinding: (input) =>
290 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
291 removeKeybinding: (input) =>
292 transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)),
293 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
294 updateSettings: (patch) =>
295 transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })),
296 subscribeConfig: (listener, options) =>
297 transport.subscribe(
298 (client) => client[WS_METHODS.subscribeServerConfig]({}),
299 listener,
300 subscriptionOptions(options, WS_METHODS.subscribeServerConfig),
301 ),
302 subscribeLifecycle: (listener, options) =>
303 transport.subscribe(
304 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
305 listener,
306 subscriptionOptions(options, WS_METHODS.subscribeServerLifecycle),
307 ),
308 subscribeAuthAccess: (listener, options) =>
309 transport.subscribe(
310 (client) => client[WS_METHODS.subscribeAuthAccess]({}),
311 listener,
312 subscriptionOptions(options, WS_METHODS.subscribeAuthAccess),
313 ),
314 getTraceDiagnostics: () =>
315 transport.request((client) => client[WS_METHODS.serverGetTraceDiagnostics]({})),
316 getProcessDiagnostics: () =>
317 transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})),
318 getProcessResourceHistory: (input) =>
319 transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)),
320 signalProcess: (input) =>
321 transport.request((client) => client[WS_METHODS.serverSignalProcess](input)),
322 },
323 orchestration: {
324 dispatchCommand: (input) =>
325 transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)),
326 getTurnDiff: (input) =>
327 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)),
328 getFullThreadDiff: (input) =>
329 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)),
330 getArchivedShellSnapshot: () =>
331 transport.request((client) =>
332 client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}),
333 ),
334 subscribeShell: (listener, options) =>
335 transport.subscribe(
336 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),
337 listener,
338 subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeShell),
339 ),
340 subscribeThread: (input, listener, options) =>
341 transport.subscribe(
342 (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input),
343 listener,
344 subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread),
345 ),
346 },
347 };
348}
packages/client-runtime/src/wsRpcProtocol.ts created+374
...@@ -0,0 +1,374 @@
1import { WsRpcGroup } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schedule from "effect/Schedule";
6import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
7import * as Socket from "effect/unstable/socket/Socket";
8
9import {
10 DEFAULT_RECONNECT_BACKOFF,
11 getReconnectDelayMs,
12 type ReconnectBackoffConfig,
13} from "./reconnectBackoff.ts";
14
15export interface WsProtocolLifecycleHandlers {
16 readonly getConnectionLabel?: () => string | null;
17 readonly getVersionMismatchHint?: () => string | null;
18 readonly isCloseIntentional?: () => boolean;
19 readonly isActive?: () => boolean;
20 readonly onAttempt?: (socketUrl: string) => void;
21 readonly onOpen?: () => void;
22 readonly onHeartbeatPing?: () => void;
23 readonly onHeartbeatPong?: () => void;
24 readonly onHeartbeatTimeout?: () => void;
25 readonly onRequestStart?: (info: {
26 readonly id: string;
27 readonly tag: string;
28 readonly stream: boolean;
29 }) => void;
30 readonly onRequestChunk?: (info: {
31 readonly id: string;
32 readonly tag: string;
33 readonly chunkCount: number;
34 }) => void;
35 readonly onRequestExit?: (info: {
36 readonly id: string;
37 readonly tag: string;
38 readonly stream: boolean;
39 }) => void;
40 readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void;
41 readonly onError?: (message: string) => void;
42 readonly onClose?: (
43 details: { readonly code: number; readonly reason: string },
44 context: { readonly intentional: boolean },
45 ) => void;
46}
47
48export interface WsRpcProtocolRequestTelemetry {
49 readonly onRequestSent?: (requestId: string, tag: string) => void;
50 readonly onRequestAcknowledged?: (requestId: string) => void;
51 readonly onClearTrackedRequests?: () => void;
52}
53
54export interface WsRpcProtocolOptions {
55 /** Backoff configuration for reconnect retries. */
56 readonly backoff?: ReconnectBackoffConfig;
57 /**
58 * Invoked before user {@link WsProtocolLifecycleHandlers} for each socket lifecycle event.
59 * Use for additive telemetry (connection state, clearing request trackers on disconnect).
60 */
61 readonly telemetryLifecycle?: WsProtocolLifecycleHandlers;
62 /** Optional hooks around outbound requests and inbound RPC responses (latency tracking, etc.). */
63 readonly requestTelemetry?: WsRpcProtocolRequestTelemetry;
64}
65
66export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
67type RpcClientFactory = typeof makeWsRpcProtocolClient;
68export type WsRpcProtocolClient =
69 RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never;
70export type WsRpcProtocolSocketUrlProvider = string | (() => Promise<string>);
71
72function formatSocketErrorMessage(error: unknown): string {
73 if (error instanceof Error && error.message.trim().length > 0) {
74 return error.message;
75 }
76
77 return String(error);
78}
79
80function resolveWsRpcSocketUrl(rawUrl: string): string {
81 const resolved = new URL(rawUrl);
82 if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") {
83 throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`);
84 }
85
86 resolved.pathname = "/ws";
87 return resolved.toString();
88}
89
90type ResolvedLifecycleHandlers = Required<
91 Pick<
92 WsProtocolLifecycleHandlers,
93 | "getConnectionLabel"
94 | "getVersionMismatchHint"
95 | "isCloseIntentional"
96 | "isActive"
97 | "onAttempt"
98 | "onOpen"
99 | "onHeartbeatPing"
100 | "onHeartbeatPong"
101 | "onHeartbeatTimeout"
102 | "onError"
103 | "onClose"
104 >
105>;
106
107function defaultLifecycleHandlers(): ResolvedLifecycleHandlers {
108 return {
109 onAttempt: () => undefined,
110 onOpen: () => undefined,
111 onHeartbeatPing: () => undefined,
112 onHeartbeatPong: () => undefined,
113 onHeartbeatTimeout: () => undefined,
114 onError: () => undefined,
115 onClose: () => undefined,
116 getConnectionLabel: () => null,
117 getVersionMismatchHint: () => null,
118 isCloseIntentional: () => false,
119 isActive: () => true,
120 };
121}
122
123function resolveLifecycleHandlers(
124 handlers: WsProtocolLifecycleHandlers | undefined,
125 telemetryLifecycle: WsProtocolLifecycleHandlers | undefined,
126): ResolvedLifecycleHandlers {
127 const defaults = defaultLifecycleHandlers();
128 const isActive = handlers?.isActive ?? telemetryLifecycle?.isActive ?? defaults.isActive;
129 const isCloseIntentional =
130 handlers?.isCloseIntentional ??
131 telemetryLifecycle?.isCloseIntentional ??
132 defaults.isCloseIntentional;
133
134 return {
135 getConnectionLabel: () =>
136 handlers?.getConnectionLabel?.() ?? telemetryLifecycle?.getConnectionLabel?.() ?? null,
137 getVersionMismatchHint: () =>
138 handlers?.getVersionMismatchHint?.() ??
139 telemetryLifecycle?.getVersionMismatchHint?.() ??
140 null,
141 isActive,
142 isCloseIntentional,
143 onAttempt: (socketUrl) => {
144 if (!isActive()) {
145 return;
146 }
147 telemetryLifecycle?.onAttempt?.(socketUrl);
148 handlers?.onAttempt?.(socketUrl);
149 },
150 onOpen: () => {
151 if (!isActive()) {
152 return;
153 }
154 telemetryLifecycle?.onOpen?.();
155 handlers?.onOpen?.();
156 },
157 onHeartbeatPing: () => {
158 if (!isActive()) {
159 return;
160 }
161 telemetryLifecycle?.onHeartbeatPing?.();
162 handlers?.onHeartbeatPing?.();
163 },
164 onHeartbeatPong: () => {
165 if (!isActive()) {
166 return;
167 }
168 telemetryLifecycle?.onHeartbeatPong?.();
169 handlers?.onHeartbeatPong?.();
170 },
171 onHeartbeatTimeout: () => {
172 if (!isActive()) {
173 return;
174 }
175 telemetryLifecycle?.onHeartbeatTimeout?.();
176 handlers?.onHeartbeatTimeout?.();
177 },
178 onError: (message) => {
179 if (!isActive()) {
180 return;
181 }
182 telemetryLifecycle?.onError?.(message);
183 handlers?.onError?.(message);
184 },
185 onClose: (details, context) => {
186 if (!isActive()) {
187 return;
188 }
189 telemetryLifecycle?.onClose?.(details, context);
190 handlers?.onClose?.(details, context);
191 },
192 };
193}
194
195export function createWsRpcProtocolLayer(
196 url: WsRpcProtocolSocketUrlProvider,
197 handlers?: WsProtocolLifecycleHandlers,
198 options?: WsRpcProtocolOptions,
199) {
200 const lifecycle = resolveLifecycleHandlers(handlers, options?.telemetryLifecycle);
201 const backoff = options?.backoff ?? DEFAULT_RECONNECT_BACKOFF;
202 const requestTelemetry = options?.requestTelemetry;
203 const instrumentRequests =
204 requestTelemetry?.onRequestSent !== undefined ||
205 requestTelemetry?.onRequestAcknowledged !== undefined ||
206 requestTelemetry?.onClearTrackedRequests !== undefined;
207
208 const resolvedUrl =
209 typeof url === "function"
210 ? Effect.promise(() => url()).pipe(
211 Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)),
212 Effect.tapError((error) =>
213 Effect.sync(() => {
214 lifecycle.onError(formatSocketErrorMessage(error));
215 }),
216 ),
217 Effect.orDie,
218 )
219 : resolveWsRpcSocketUrl(url);
220
221 const trackingWebSocketConstructorLayer = Layer.succeed(
222 Socket.WebSocketConstructor,
223 (socketUrl, protocols) => {
224 lifecycle.onAttempt(socketUrl);
225 const socket = new globalThis.WebSocket(socketUrl, protocols);
226
227 socket.addEventListener(
228 "open",
229 () => {
230 lifecycle.onOpen();
231 },
232 { once: true },
233 );
234 socket.addEventListener(
235 "error",
236 () => {
237 lifecycle.onError("Unable to connect to the T3 server WebSocket.");
238 },
239 { once: true },
240 );
241 socket.addEventListener(
242 "close",
243 (event) => {
244 lifecycle.onClose(
245 {
246 code: event.code,
247 reason: event.reason,
248 },
249 {
250 intentional: lifecycle.isCloseIntentional(),
251 },
252 );
253 },
254 { once: true },
255 );
256
257 return socket;
258 },
259 );
260 const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe(
261 Layer.provide(trackingWebSocketConstructorLayer),
262 );
263
264 const baseSchedule =
265 backoff.maxRetries === null ? Schedule.forever : Schedule.recurs(backoff.maxRetries);
266 const retryPolicy = Schedule.addDelay(baseSchedule, (retryCount) =>
267 Effect.succeed(Duration.millis(getReconnectDelayMs(retryCount, backoff) ?? 0)),
268 );
269 const protocolLayer = Layer.effect(
270 RpcClient.Protocol,
271 instrumentRequests
272 ? Effect.map(
273 RpcClient.makeProtocolSocket({
274 retryPolicy,
275 retryTransientErrors: true,
276 }),
277 (protocol) => ({
278 ...protocol,
279 run: (clientId, writeResponse) =>
280 protocol.run(clientId, (response) => {
281 if (response._tag === "Chunk" || response._tag === "Exit") {
282 requestTelemetry?.onRequestAcknowledged?.(response.requestId);
283 } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") {
284 requestTelemetry?.onClearTrackedRequests?.();
285 }
286 return writeResponse(response);
287 }),
288 send: (clientId, request, transferables) => {
289 if (request._tag === "Request") {
290 requestTelemetry?.onRequestSent?.(request.id, request.tag);
291 }
292 return protocol.send(clientId, request, transferables);
293 },
294 }),
295 )
296 : RpcClient.makeProtocolSocket({
297 retryPolicy,
298 retryTransientErrors: true,
299 }),
300 );
301 const requestHooksLayer = Layer.succeed(
302 RpcClient.RequestHooks,
303 RpcClient.RequestHooks.of({
304 onRequestStart: (info) =>
305 Effect.sync(() => {
306 if (!lifecycle.isActive()) {
307 return;
308 }
309 handlers?.onRequestStart?.({
310 id: String(info.id),
311 tag: info.tag,
312 stream: info.stream,
313 });
314 }),
315 onRequestChunk: (info) =>
316 Effect.sync(() => {
317 if (!lifecycle.isActive()) {
318 return;
319 }
320 handlers?.onRequestChunk?.({
321 id: String(info.id),
322 tag: info.tag,
323 chunkCount: info.chunkCount,
324 });
325 }),
326 onRequestExit: (info) =>
327 Effect.sync(() => {
328 if (!lifecycle.isActive()) {
329 return;
330 }
331 handlers?.onRequestExit?.({
332 id: String(info.id),
333 tag: info.tag,
334 stream: info.stream,
335 });
336 }),
337 onRequestInterrupt: (info) =>
338 Effect.sync(() => {
339 if (!lifecycle.isActive()) {
340 return;
341 }
342 handlers?.onRequestInterrupt?.({
343 id: String(info.id),
344 ...(info.tag === undefined ? {} : { tag: info.tag }),
345 });
346 }),
347 }),
348 );
349 const connectionHooksLayer = Layer.succeed(
350 RpcClient.ConnectionHooks,
351 RpcClient.ConnectionHooks.of({
352 onConnect: Effect.void,
353 onDisconnect: Effect.void,
354 onPing: Effect.sync(() => {
355 lifecycle.onHeartbeatPing();
356 }),
357 onPong: Effect.sync(() => {
358 lifecycle.onHeartbeatPong();
359 }),
360 onPingTimeout: Effect.sync(() => {
361 requestTelemetry?.onClearTrackedRequests?.();
362 lifecycle.onHeartbeatTimeout();
363 }),
364 }),
365 );
366
367 return Layer.mergeAll(
368 protocolLayer.pipe(
369 Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)),
370 ),
371 requestHooksLayer,
372 connectionHooksLayer,
373 );
374}
packages/client-runtime/src/wsTransport.test.ts created+959
...@@ -0,0 +1,959 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
7import { WsTransport } from "./wsTransport.ts";
8
9type WsEventType = "open" | "message" | "close" | "error";
10type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
11type WsListener = (event?: WsEvent) => void;
12
13const sockets: MockWebSocket[] = [];
14
15class MockWebSocket {
16 static readonly CONNECTING = 0;
17 static readonly OPEN = 1;
18 static readonly CLOSING = 2;
19 static readonly CLOSED = 3;
20
21 readyState = MockWebSocket.CONNECTING;
22 readonly sent: string[] = [];
23 readonly url: string;
24 private readonly listeners = new Map<WsEventType, Set<WsListener>>();
25
26 constructor(url: string) {
27 this.url = url;
28 sockets.push(this);
29 }
30
31 addEventListener(type: WsEventType, listener: WsListener) {
32 const listeners = this.listeners.get(type) ?? new Set<WsListener>();
33 listeners.add(listener);
34 this.listeners.set(type, listeners);
35 }
36
37 removeEventListener(type: WsEventType, listener: WsListener) {
38 this.listeners.get(type)?.delete(listener);
39 }
40
41 send(data: string) {
42 this.sent.push(data);
43 }
44
45 close(code = 1000, reason = "") {
46 this.readyState = MockWebSocket.CLOSED;
47 this.emit("close", { code, reason, type: "close" });
48 }
49
50 open() {
51 this.readyState = MockWebSocket.OPEN;
52 this.emit("open", { type: "open" });
53 }
54
55 serverMessage(data: unknown) {
56 this.emit("message", { data, type: "message" });
57 }
58
59 error() {
60 this.emit("error", { type: "error" });
61 }
62
63 private emit(type: WsEventType, event?: WsEvent) {
64 const listeners = this.listeners.get(type);
65 if (!listeners) return;
66 for (const listener of listeners) {
67 listener(event);
68 }
69 }
70}
71
72const originalWebSocket = globalThis.WebSocket;
73const originalFetch = globalThis.fetch;
74const transports: WsTransport[] = [];
75
76function getSocket(): MockWebSocket {
77 const socket = sockets.at(-1);
78 if (!socket) {
79 throw new Error("Expected a websocket instance");
80 }
81 return socket;
82}
83
84async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> {
85 const startedAt = performance.now();
86 for (;;) {
87 try {
88 assertion();
89 return;
90 } catch (error) {
91 if (performance.now() - startedAt >= timeoutMs) {
92 throw error;
93 }
94 await Effect.runPromise(Effect.sleep(Duration.millis(10)));
95 }
96 }
97}
98
99function createTransport(...args: ConstructorParameters<typeof WsTransport>): WsTransport {
100 const transport = new WsTransport(...args);
101 transports.push(transport);
102 return transport;
103}
104
105beforeEach(() => {
106 vi.useRealTimers();
107 sockets.length = 0;
108 transports.length = 0;
109
110 Object.defineProperty(globalThis, "window", {
111 configurable: true,
112 value: {
113 location: {
114 origin: "http://localhost:3020",
115 hostname: "localhost",
116 port: "3020",
117 protocol: "http:",
118 },
119 desktopBridge: undefined,
120 },
121 });
122 Object.defineProperty(globalThis, "navigator", {
123 configurable: true,
124 value: { onLine: true },
125 });
126
127 globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
128});
129
130afterEach(async () => {
131 await Promise.allSettled(transports.map((transport) => transport.dispose()));
132 transports.length = 0;
133 globalThis.WebSocket = originalWebSocket;
134 globalThis.fetch = originalFetch;
135 vi.restoreAllMocks();
136});
137
138describe("WsTransport", () => {
139 it("normalizes root websocket urls to /ws and preserves query params", async () => {
140 const transport = createTransport("ws://localhost:3020/?token=secret-token");
141
142 await waitFor(() => {
143 expect(sockets).toHaveLength(1);
144 });
145
146 expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token");
147 await transport.dispose();
148 });
149
150 it("uses an explicit secure websocket base url", async () => {
151 const transport = createTransport("wss://app.example.com");
152
153 await waitFor(() => {
154 expect(sockets).toHaveLength(1);
155 });
156
157 expect(getSocket().url).toBe("wss://app.example.com/ws");
158 await transport.dispose();
159 });
160
161 it("uses an explicit insecure websocket base url for remote backends", async () => {
162 const transport = createTransport("ws://192.168.1.44:3773");
163
164 await waitFor(() => {
165 expect(sockets).toHaveLength(1);
166 });
167
168 expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws");
169 await transport.dispose();
170 });
171
172 it("supports async websocket url providers", async () => {
173 const transport = createTransport(async () => "wss://remote.example.com/?wsToken=dynamic");
174
175 await waitFor(() => {
176 expect(sockets).toHaveLength(1);
177 });
178
179 expect(getSocket().url).toBe("wss://remote.example.com/ws?wsToken=dynamic");
180 await transport.dispose();
181 });
182
183 it("invokes optional lifecycle handlers when the socket opens and closes", async () => {
184 const onOpen = vi.fn();
185 const onClose = vi.fn();
186 const transport = createTransport("ws://localhost:3020", {
187 onOpen,
188 onClose,
189 });
190
191 await waitFor(() => {
192 expect(sockets).toHaveLength(1);
193 });
194
195 const socket = getSocket();
196 socket.open();
197
198 await waitFor(() => {
199 expect(onOpen).toHaveBeenCalledOnce();
200 });
201
202 socket.close(1012, "service restart");
203
204 await waitFor(() => {
205 expect(onClose).toHaveBeenCalledWith(
206 {
207 code: 1012,
208 reason: "service restart",
209 },
210 {
211 intentional: false,
212 },
213 );
214 });
215
216 await transport.dispose();
217 });
218
219 it("tracks heartbeat freshness from websocket pongs", async () => {
220 const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000);
221 const onHeartbeatPong = vi.fn();
222 const transport = createTransport("ws://localhost:3020", { onHeartbeatPong });
223
224 await waitFor(() => {
225 expect(sockets).toHaveLength(1);
226 });
227
228 expect(transport.isHeartbeatFresh()).toBe(false);
229
230 const socket = getSocket();
231 socket.open();
232 socket.serverMessage(JSON.stringify({ _tag: "Pong" }));
233
234 await waitFor(() => {
235 expect(onHeartbeatPong).toHaveBeenCalledOnce();
236 });
237
238 expect(transport.isHeartbeatFresh()).toBe(true);
239 expect(transport.isHeartbeatFresh(500)).toBe(true);
240
241 nowSpy.mockReturnValue(1_501);
242 expect(transport.isHeartbeatFresh(500)).toBe(false);
243
244 await transport.dispose();
245 });
246
247 it("clears heartbeat freshness when reconnecting", async () => {
248 vi.spyOn(performance, "now").mockReturnValue(1_000);
249 const onHeartbeatPong = vi.fn();
250 const transport = createTransport("ws://localhost:3020", { onHeartbeatPong });
251
252 await waitFor(() => {
253 expect(sockets).toHaveLength(1);
254 });
255
256 const firstSocket = getSocket();
257 firstSocket.open();
258 firstSocket.serverMessage(JSON.stringify({ _tag: "Pong" }));
259
260 await waitFor(() => {
261 expect(onHeartbeatPong).toHaveBeenCalledOnce();
262 });
263 expect(transport.isHeartbeatFresh()).toBe(true);
264
265 await transport.reconnect();
266
267 expect(transport.isHeartbeatFresh()).toBe(false);
268
269 await transport.dispose();
270 });
271
272 it("does not report an intentional dispose as a close", async () => {
273 const onClose = vi.fn();
274 const transport = createTransport("ws://localhost:3020", { onClose });
275
276 await waitFor(() => {
277 expect(sockets).toHaveLength(1);
278 });
279
280 getSocket().open();
281 await transport.dispose();
282
283 expect(onClose).not.toHaveBeenCalled();
284 });
285
286 it("ignores stale socket lifecycle events after reconnect starts a new session", async () => {
287 const onClose = vi.fn();
288 const transport = createTransport("ws://localhost:3020", { onClose });
289
290 await waitFor(() => {
291 expect(sockets).toHaveLength(1);
292 });
293
294 const firstSocket = getSocket();
295 firstSocket.open();
296
297 await transport.reconnect();
298
299 await waitFor(() => {
300 expect(sockets).toHaveLength(2);
301 });
302
303 firstSocket.close(1006, "stale close");
304
305 expect(onClose).not.toHaveBeenCalled();
306
307 await transport.dispose();
308 });
309
310 it("reconnects the websocket session without disposing the transport", async () => {
311 const transport = createTransport("ws://localhost:3020");
312
313 await waitFor(() => {
314 expect(sockets).toHaveLength(1);
315 });
316
317 const firstSocket = getSocket();
318 firstSocket.open();
319
320 await transport.reconnect();
321
322 await waitFor(() => {
323 expect(sockets).toHaveLength(2);
324 });
325
326 const secondSocket = getSocket();
327 expect(secondSocket).not.toBe(firstSocket);
328 expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED);
329
330 const requestPromise = transport.request((client) =>
331 client[WS_METHODS.serverUpsertKeybinding]({
332 command: "terminal.toggle",
333 key: "ctrl+k",
334 }),
335 );
336
337 secondSocket.open();
338
339 await waitFor(() => {
340 expect(secondSocket.sent).toHaveLength(1);
341 });
342
343 const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string };
344 secondSocket.serverMessage(
345 JSON.stringify({
346 _tag: "Exit",
347 requestId: requestMessage.id,
348 exit: {
349 _tag: "Success",
350 value: {
351 keybindings: [],
352 issues: [],
353 },
354 },
355 }),
356 );
357
358 await expect(requestPromise).resolves.toEqual({
359 keybindings: [],
360 issues: [],
361 });
362
363 await transport.dispose();
364 });
365
366 it("sends unary RPC requests and resolves successful exits", async () => {
367 const transport = createTransport("ws://localhost:3020");
368
369 const requestPromise = transport.request((client) =>
370 client[WS_METHODS.serverUpsertKeybinding]({
371 command: "terminal.toggle",
372 key: "ctrl+k",
373 }),
374 );
375
376 await waitFor(() => {
377 expect(sockets).toHaveLength(1);
378 });
379
380 const socket = getSocket();
381 socket.open();
382
383 await waitFor(() => {
384 expect(socket.sent).toHaveLength(1);
385 });
386
387 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as {
388 _tag: string;
389 id: string;
390 payload: unknown;
391 tag: string;
392 };
393 expect(requestMessage).toMatchObject({
394 _tag: "Request",
395 tag: WS_METHODS.serverUpsertKeybinding,
396 payload: {
397 command: "terminal.toggle",
398 key: "ctrl+k",
399 },
400 });
401
402 socket.serverMessage(
403 JSON.stringify({
404 _tag: "Exit",
405 requestId: requestMessage.id,
406 exit: {
407 _tag: "Success",
408 value: {
409 keybindings: [],
410 issues: [],
411 },
412 },
413 }),
414 );
415
416 await expect(requestPromise).resolves.toEqual({
417 keybindings: [],
418 issues: [],
419 });
420
421 await transport.dispose();
422 });
423
424 it("delivers stream chunks to subscribers", async () => {
425 const transport = createTransport("ws://localhost:3020");
426 const listener = vi.fn();
427
428 const unsubscribe = transport.subscribe(
429 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
430 listener,
431 );
432 await waitFor(() => {
433 expect(sockets).toHaveLength(1);
434 });
435
436 const socket = getSocket();
437 socket.open();
438
439 await waitFor(() => {
440 expect(socket.sent).toHaveLength(1);
441 });
442
443 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
444 expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle);
445
446 const welcomeEvent = {
447 version: 1,
448 sequence: 1,
449 type: "welcome",
450 payload: {
451 environment: {
452 environmentId: "environment-local",
453 label: "Local environment",
454 platform: { os: "darwin", arch: "arm64" },
455 serverVersion: "0.0.0-test",
456 capabilities: { repositoryIdentity: true },
457 },
458 cwd: "/tmp/workspace",
459 projectName: "workspace",
460 },
461 };
462
463 socket.serverMessage(
464 JSON.stringify({
465 _tag: "Chunk",
466 requestId: requestMessage.id,
467 values: [welcomeEvent],
468 }),
469 );
470
471 await waitFor(() => {
472 expect(listener).toHaveBeenCalledWith(welcomeEvent);
473 });
474
475 unsubscribe();
476 await transport.dispose();
477 });
478
479 it("re-subscribes stream listeners after the stream exits", async () => {
480 const transport = createTransport("ws://localhost:3020");
481 const listener = vi.fn();
482 const onResubscribe = vi.fn();
483
484 const unsubscribe = transport.subscribe(
485 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
486 listener,
487 { onResubscribe },
488 );
489 await waitFor(() => {
490 expect(sockets).toHaveLength(1);
491 });
492
493 const socket = getSocket();
494 socket.open();
495
496 await waitFor(() => {
497 expect(socket.sent).toHaveLength(1);
498 });
499
500 const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
501 socket.serverMessage(
502 JSON.stringify({
503 _tag: "Chunk",
504 requestId: firstRequest.id,
505 values: [
506 {
507 version: 1,
508 sequence: 1,
509 type: "welcome",
510 payload: {
511 environment: {
512 environmentId: "environment-local",
513 label: "Local environment",
514 platform: { os: "darwin", arch: "arm64" },
515 serverVersion: "0.0.0-test",
516 capabilities: { repositoryIdentity: true },
517 },
518 cwd: "/tmp/one",
519 projectName: "one",
520 },
521 },
522 ],
523 }),
524 );
525 socket.serverMessage(
526 JSON.stringify({
527 _tag: "Exit",
528 requestId: firstRequest.id,
529 exit: {
530 _tag: "Success",
531 value: null,
532 },
533 }),
534 );
535
536 await waitFor(() => {
537 const nextRequest = socket.sent
538 .map((message) => JSON.parse(message) as { _tag?: string; id?: string })
539 .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
540 expect(nextRequest).toBeDefined();
541 });
542 expect(onResubscribe).toHaveBeenCalledOnce();
543
544 const secondRequest = socket.sent
545 .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string })
546 .find(
547 (message): message is { _tag: "Request"; id: string; tag: string } =>
548 message._tag === "Request" && message.id !== firstRequest.id,
549 );
550 if (!secondRequest) {
551 throw new Error("Expected a resubscribe request");
552 }
553 expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle);
554 expect(secondRequest.id).not.toBe(firstRequest.id);
555
556 const secondEvent = {
557 version: 1,
558 sequence: 2,
559 type: "welcome",
560 payload: {
561 environment: {
562 environmentId: "environment-local",
563 label: "Local environment",
564 platform: { os: "darwin", arch: "arm64" },
565 serverVersion: "0.0.0-test",
566 capabilities: { repositoryIdentity: true },
567 },
568 cwd: "/tmp/two",
569 projectName: "two",
570 },
571 };
572 socket.serverMessage(
573 JSON.stringify({
574 _tag: "Chunk",
575 requestId: secondRequest.id,
576 values: [secondEvent],
577 }),
578 );
579
580 await waitFor(() => {
581 expect(listener).toHaveBeenLastCalledWith(secondEvent);
582 });
583
584 unsubscribe();
585 await transport.dispose();
586 });
587
588 it("re-subscribes live stream listeners after an explicit transport reconnect", async () => {
589 const transport = createTransport("ws://localhost:3020");
590 const listener = vi.fn();
591 const onResubscribe = vi.fn();
592
593 const unsubscribe = transport.subscribe(
594 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
595 listener,
596 { onResubscribe },
597 );
598
599 await waitFor(() => {
600 expect(sockets).toHaveLength(1);
601 });
602
603 const firstSocket = getSocket();
604 firstSocket.open();
605
606 await waitFor(() => {
607 expect(firstSocket.sent).toHaveLength(1);
608 });
609
610 const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string };
611 const firstEvent = {
612 version: 1,
613 sequence: 1,
614 type: "welcome",
615 payload: {
616 environment: {
617 environmentId: "environment-local",
618 label: "Local environment",
619 platform: { os: "darwin", arch: "arm64" },
620 serverVersion: "0.0.0-test",
621 capabilities: { repositoryIdentity: true },
622 },
623 cwd: "/tmp/one",
624 projectName: "one",
625 },
626 };
627
628 firstSocket.serverMessage(
629 JSON.stringify({
630 _tag: "Chunk",
631 requestId: firstRequest.id,
632 values: [firstEvent],
633 }),
634 );
635
636 await waitFor(() => {
637 expect(listener).toHaveBeenLastCalledWith(firstEvent);
638 });
639
640 await transport.reconnect();
641
642 await waitFor(() => {
643 expect(sockets).toHaveLength(2);
644 });
645
646 const secondSocket = getSocket();
647 expect(secondSocket).not.toBe(firstSocket);
648 expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED);
649
650 secondSocket.open();
651
652 await waitFor(() => {
653 expect(secondSocket.sent).toHaveLength(1);
654 });
655
656 const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as {
657 id: string;
658 tag: string;
659 };
660 expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle);
661 expect(secondRequest.id).not.toBe(firstRequest.id);
662 expect(onResubscribe).toHaveBeenCalledOnce();
663
664 const secondEvent = {
665 version: 1,
666 sequence: 2,
667 type: "welcome",
668 payload: {
669 environment: {
670 environmentId: "environment-local",
671 label: "Local environment",
672 platform: { os: "darwin", arch: "arm64" },
673 serverVersion: "0.0.0-test",
674 capabilities: { repositoryIdentity: true },
675 },
676 cwd: "/tmp/two",
677 projectName: "two",
678 },
679 };
680
681 secondSocket.serverMessage(
682 JSON.stringify({
683 _tag: "Chunk",
684 requestId: secondRequest.id,
685 values: [secondEvent],
686 }),
687 );
688
689 await waitFor(() => {
690 expect(listener).toHaveBeenLastCalledWith(secondEvent);
691 });
692
693 unsubscribe();
694 await transport.dispose();
695 });
696
697 it("does not fire onResubscribe when the first stream attempt exits before any value", async () => {
698 const transport = createTransport("ws://localhost:3020");
699 const listener = vi.fn();
700 const onResubscribe = vi.fn();
701
702 const unsubscribe = transport.subscribe(
703 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
704 listener,
705 { onResubscribe },
706 );
707 await waitFor(() => {
708 expect(sockets).toHaveLength(1);
709 });
710
711 const socket = getSocket();
712 socket.open();
713
714 await waitFor(() => {
715 expect(socket.sent).toHaveLength(1);
716 });
717
718 const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
719 socket.serverMessage(
720 JSON.stringify({
721 _tag: "Exit",
722 requestId: firstRequest.id,
723 exit: {
724 _tag: "Success",
725 value: null,
726 },
727 }),
728 );
729
730 await waitFor(() => {
731 const nextRequest = socket.sent
732 .map((message) => JSON.parse(message) as { _tag?: string; id?: string })
733 .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
734 expect(nextRequest).toBeDefined();
735 });
736 expect(onResubscribe).not.toHaveBeenCalled();
737 expect(listener).not.toHaveBeenCalled();
738
739 unsubscribe();
740 await transport.dispose();
741 });
742
743 it("does not retry stream subscriptions after application-level failures", async () => {
744 const warnSpy = vi.fn();
745 const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy });
746 let attempts = 0;
747
748 const unsubscribe = transport.subscribe(
749 () =>
750 Stream.suspend(() => {
751 attempts += 1;
752 return Stream.fail(new Error("Git command failed in GitCore.statusDetails"));
753 }),
754 vi.fn(),
755 { retryDelay: 10 },
756 );
757
758 await waitFor(() => {
759 expect(sockets).toHaveLength(1);
760 });
761
762 getSocket().open();
763
764 await waitFor(() => {
765 expect(attempts).toBe(1);
766 });
767 await Effect.runPromise(Effect.sleep(Duration.millis(50)));
768
769 expect(attempts).toBe(1);
770 expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", {
771 error: "Git command failed in GitCore.statusDetails",
772 });
773 expect(warnSpy).not.toHaveBeenCalledWith(
774 "WebSocket RPC subscription disconnected",
775 expect.anything(),
776 );
777
778 unsubscribe();
779 await transport.dispose();
780 });
781
782 it("keeps retrying stream subscriptions after transport failures", async () => {
783 const warnSpy = vi.fn();
784 const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy });
785 let attempts = 0;
786
787 const unsubscribe = transport.subscribe(
788 () =>
789 Stream.suspend(() => {
790 attempts += 1;
791 return Stream.fail(new Error("SocketCloseError: WebSocket closed"));
792 }),
793 vi.fn(),
794 { retryDelay: 10 },
795 );
796
797 await waitFor(() => {
798 expect(sockets).toHaveLength(1);
799 });
800
801 getSocket().open();
802
803 await waitFor(() => {
804 expect(attempts).toBeGreaterThanOrEqual(2);
805 });
806
807 expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", {
808 error: "SocketCloseError: WebSocket closed",
809 });
810
811 unsubscribe();
812 await transport.dispose();
813 });
814
815 it("logs a transport disconnect once even when multiple subscriptions fail together", async () => {
816 const warnSpy = vi.fn();
817 const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy });
818
819 const unsubscribeA = transport.subscribe(
820 () => Stream.fail(new Error("SocketCloseError: 1006")),
821 vi.fn(),
822 { retryDelay: 10 },
823 );
824 const unsubscribeB = transport.subscribe(
825 () => Stream.fail(new Error("SocketCloseError: 1006")),
826 vi.fn(),
827 { retryDelay: 10 },
828 );
829
830 await waitFor(() => {
831 expect(sockets).toHaveLength(1);
832 });
833
834 getSocket().open();
835
836 await waitFor(() => {
837 expect(warnSpy).toHaveBeenCalledTimes(1);
838 });
839 expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", {
840 error: "SocketCloseError: 1006",
841 });
842
843 unsubscribeA();
844 unsubscribeB();
845 await transport.dispose();
846 });
847
848 it("streams finite request events without re-subscribing", async () => {
849 const transport = createTransport("ws://localhost:3020");
850 const listener = vi.fn();
851
852 await waitFor(() => {
853 expect(sockets).toHaveLength(1);
854 });
855 const socket = getSocket();
856 socket.open();
857
858 const requestPromise = transport.requestStream(
859 (client) =>
860 client[WS_METHODS.gitRunStackedAction]({
861 actionId: "action-1",
862 cwd: "/repo",
863 action: "commit",
864 }),
865 listener,
866 );
867
868 await waitFor(() => {
869 expect(socket.sent).toHaveLength(1);
870 });
871
872 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
873 const progressEvent = {
874 actionId: "action-1",
875 cwd: "/repo",
876 action: "commit",
877 kind: "phase_started",
878 phase: "commit",
879 label: "Committing...",
880 } as const;
881
882 socket.serverMessage(
883 JSON.stringify({
884 _tag: "Chunk",
885 requestId: requestMessage.id,
886 values: [progressEvent],
887 }),
888 );
889 socket.serverMessage(
890 JSON.stringify({
891 _tag: "Exit",
892 requestId: requestMessage.id,
893 exit: {
894 _tag: "Success",
895 value: null,
896 },
897 }),
898 );
899
900 await expect(requestPromise).resolves.toBeUndefined();
901 expect(listener).toHaveBeenCalledWith(progressEvent);
902 expect(
903 socket.sent.filter((message) => {
904 const parsed = JSON.parse(message) as { _tag?: string; tag?: string };
905 return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction;
906 }),
907 ).toHaveLength(1);
908 await transport.dispose();
909 });
910
911 it("closes the client scope on the transport runtime before disposing the runtime", async () => {
912 const callOrder: string[] = [];
913 let resolveClose!: () => void;
914 const closePromise = new Promise<void>((resolve) => {
915 resolveClose = resolve;
916 });
917
918 const runtime = {
919 runPromise: vi.fn(async () => {
920 callOrder.push("close:start");
921 await closePromise;
922 callOrder.push("close:done");
923 return undefined;
924 }),
925 dispose: vi.fn(async () => {
926 callOrder.push("runtime:dispose");
927 }),
928 };
929 const transport = {
930 disposed: false,
931 session: {
932 clientScope: {} as never,
933 runtime,
934 },
935 closeSession: (
936 WsTransport.prototype as unknown as {
937 closeSession: (session: {
938 clientScope: unknown;
939 runtime: { dispose: () => Promise<void>; runPromise: () => Promise<void> };
940 }) => Promise<void>;
941 }
942 ).closeSession,
943 } as unknown as WsTransport;
944
945 void WsTransport.prototype.dispose.call(transport);
946
947 expect(runtime.runPromise).toHaveBeenCalledTimes(1);
948 expect(runtime.dispose).not.toHaveBeenCalled();
949 expect((transport as unknown as { disposed: boolean }).disposed).toBe(true);
950
951 resolveClose();
952
953 await waitFor(() => {
954 expect(runtime.dispose).toHaveBeenCalledTimes(1);
955 });
956
957 expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]);
958 });
959});
packages/client-runtime/src/wsTransport.ts created+370
...@@ -0,0 +1,370 @@
1import * as Cause from "effect/Cause";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Layer from "effect/Layer";
6import * as ManagedRuntime from "effect/ManagedRuntime";
7import * as Scope from "effect/Scope";
8import * as Stream from "effect/Stream";
9import { RpcClient } from "effect/unstable/rpc";
10
11import { isTransportConnectionErrorMessage } from "./transportError.ts";
12import {
13 createWsRpcProtocolLayer,
14 makeWsRpcProtocolClient,
15 type WsProtocolLifecycleHandlers,
16 type WsRpcProtocolClient,
17 type WsRpcProtocolSocketUrlProvider,
18} from "./wsRpcProtocol.ts";
19
20export interface WsTransportOptions {
21 /**
22 * Merged into the transport `ManagedRuntime` alongside the RPC protocol layer
23 * (for example a `Tracer` layer for OTLP).
24 */
25 readonly tracingLayer?: Layer.Layer<never, never, never>;
26 /**
27 * Override protocol construction (defaults to {@link createWsRpcProtocolLayer}).
28 * The web app supplies its instrumented layer factory.
29 */
30 readonly createProtocolLayer?: (
31 url: WsRpcProtocolSocketUrlProvider,
32 lifecycleHandlers?: WsProtocolLifecycleHandlers,
33 ) => Layer.Layer<RpcClient.Protocol, never, never>;
34 readonly logWarning?: (message: string, metadata: { readonly error: string }) => void;
35 /**
36 * Invoked at the start of {@link WsTransport.reconnect} before the session is replaced.
37 */
38 readonly onBeforeReconnect?: () => void;
39}
40
41interface SubscribeOptions {
42 readonly retryDelay?: Duration.Input;
43 readonly onResubscribe?: () => void;
44 readonly tag?: string;
45}
46
47const DEFAULT_SUBSCRIPTION_RETRY_DELAY = Duration.millis(250);
48const NOOP: () => void = () => undefined;
49
50interface TransportSession {
51 readonly clientPromise: Promise<WsRpcProtocolClient>;
52 readonly clientScope: Scope.Closeable;
53 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
54}
55
56function formatErrorMessage(error: unknown): string {
57 if (error instanceof Error && error.message.trim().length > 0) {
58 return error.message;
59 }
60
61 return String(error);
62}
63
64export class WsTransport {
65 private readonly url: WsRpcProtocolSocketUrlProvider;
66 private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined;
67 private readonly options: WsTransportOptions | undefined;
68 private disposed = false;
69 private hasReportedTransportDisconnect = false;
70 private intentionalCloseDepth = 0;
71 private nextSessionId = 0;
72 private activeSessionId = 0;
73 private lastHeartbeatPongAt: number | null = null;
74 private readonly streamRequestStartListeners = new Set<
75 (info: { readonly tag: string }) => void
76 >();
77 private reconnectChain: Promise<void> = Promise.resolve();
78 private session: TransportSession;
79
80 constructor(
81 url: WsRpcProtocolSocketUrlProvider,
82 lifecycleHandlers?: WsProtocolLifecycleHandlers,
83 options?: WsTransportOptions,
84 ) {
85 this.url = url;
86 this.lifecycleHandlers = lifecycleHandlers;
87 this.options = options;
88 this.session = this.createSession();
89 }
90
91 async request<TSuccess>(
92 execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>,
93 ): Promise<TSuccess> {
94 if (this.disposed) {
95 throw new Error("Transport disposed");
96 }
97
98 const session = this.session;
99 const client = await session.clientPromise;
100 return await session.runtime.runPromise(Effect.suspend(() => execute(client)));
101 }
102
103 async requestStream<TValue>(
104 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
105 listener: (value: TValue) => void,
106 ): Promise<void> {
107 if (this.disposed) {
108 throw new Error("Transport disposed");
109 }
110
111 const session = this.session;
112 const client = await session.clientPromise;
113 await session.runtime.runPromise(
114 Stream.runForEach(connect(client), (value) =>
115 Effect.sync(() => {
116 try {
117 listener(value);
118 } catch {
119 // Ignore listener errors so the stream can finish cleanly.
120 }
121 }),
122 ),
123 );
124 }
125
126 subscribe<TValue>(
127 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
128 listener: (value: TValue) => void,
129 options?: SubscribeOptions,
130 ): () => void {
131 if (this.disposed) {
132 return NOOP;
133 }
134
135 let active = true;
136 let hasReceivedValue = false;
137 const retryDelayMs = Duration.toMillis(
138 Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY),
139 );
140 let cancelCurrentStream: () => void = NOOP;
141 const onStreamRequestStart = (info: { readonly tag: string }) => {
142 if (
143 !hasReceivedValue ||
144 !active ||
145 (options?.tag !== undefined && info.tag !== options.tag)
146 ) {
147 return;
148 }
149
150 try {
151 options?.onResubscribe?.();
152 } catch {
153 // Ignore reconnect hook failures so the stream can recover.
154 }
155 };
156 this.streamRequestStartListeners.add(onStreamRequestStart);
157
158 void (async () => {
159 for (;;) {
160 if (!active || this.disposed) {
161 return;
162 }
163
164 const session = this.session;
165 try {
166 const runningStream = this.runStreamOnSession(
167 session,
168 connect,
169 listener,
170 () => active,
171 () => {
172 this.hasReportedTransportDisconnect = false;
173 hasReceivedValue = true;
174 },
175 );
176 cancelCurrentStream = runningStream.cancel;
177 await runningStream.completed;
178 cancelCurrentStream = NOOP;
179 } catch (error) {
180 cancelCurrentStream = NOOP;
181 if (!active || this.disposed) {
182 return;
183 }
184
185 // Skip retry if the session has already been replaced by a reconnect.
186 if (session !== this.session) {
187 continue;
188 }
189
190 const formattedError = formatErrorMessage(error);
191 if (!isTransportConnectionErrorMessage(formattedError)) {
192 this.logWarning("WebSocket RPC subscription failed", { error: formattedError });
193 return;
194 }
195
196 if (!this.hasReportedTransportDisconnect) {
197 this.logWarning("WebSocket RPC subscription disconnected", {
198 error: formattedError,
199 });
200 }
201 this.hasReportedTransportDisconnect = true;
202 await sleep(retryDelayMs);
203 }
204 }
205 })();
206
207 return () => {
208 active = false;
209 this.streamRequestStartListeners.delete(onStreamRequestStart);
210 cancelCurrentStream();
211 };
212 }
213
214 async reconnect() {
215 if (this.disposed) {
216 throw new Error("Transport disposed");
217 }
218
219 const reconnectOperation = this.reconnectChain.then(async () => {
220 if (this.disposed) {
221 throw new Error("Transport disposed");
222 }
223
224 try {
225 this.options?.onBeforeReconnect?.();
226 } catch {
227 // Ignore hook failures so reconnect can proceed.
228 }
229
230 this.lastHeartbeatPongAt = null;
231 const previousSession = this.session;
232 this.session = this.createSession();
233 await this.closeSession(previousSession);
234 });
235
236 this.reconnectChain = reconnectOperation.catch(() => undefined);
237 await reconnectOperation;
238 }
239
240 isHeartbeatFresh(maxAgeMs = 15_000): boolean {
241 return (
242 this.lastHeartbeatPongAt !== null && performance.now() - this.lastHeartbeatPongAt <= maxAgeMs
243 );
244 }
245
246 async dispose() {
247 if (this.disposed) {
248 return;
249 }
250
251 this.disposed = true;
252 await this.closeSession(this.session);
253 }
254
255 private closeSession(session: TransportSession) {
256 this.intentionalCloseDepth += 1;
257 return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => {
258 this.intentionalCloseDepth = Math.max(0, this.intentionalCloseDepth - 1);
259 session.runtime.dispose();
260 });
261 }
262
263 private createSession(): TransportSession {
264 const protocolFactory = this.options?.createProtocolLayer ?? createWsRpcProtocolLayer;
265 const sessionId = this.nextSessionId + 1;
266 this.nextSessionId = sessionId;
267 this.activeSessionId = sessionId;
268 const lifecycleHandlers = this.lifecycleHandlers;
269 const protocolLayer = protocolFactory(this.url, {
270 ...lifecycleHandlers,
271 isActive: () =>
272 !this.disposed &&
273 this.activeSessionId === sessionId &&
274 (lifecycleHandlers?.isActive?.() ?? true),
275 isCloseIntentional: () =>
276 this.disposed ||
277 this.intentionalCloseDepth > 0 ||
278 lifecycleHandlers?.isCloseIntentional?.() === true,
279 onHeartbeatPong: () => {
280 this.lastHeartbeatPongAt = performance.now();
281 lifecycleHandlers?.onHeartbeatPong?.();
282 },
283 onRequestStart: (info) => {
284 lifecycleHandlers?.onRequestStart?.(info);
285 if (!info.stream) {
286 return;
287 }
288 for (const listener of this.streamRequestStartListeners) {
289 listener({ tag: info.tag });
290 }
291 },
292 });
293 const rootLayer = this.options?.tracingLayer
294 ? Layer.mergeAll(protocolLayer, this.options.tracingLayer)
295 : protocolLayer;
296 const runtime = ManagedRuntime.make(rootLayer);
297 const clientScope = runtime.runSync(Scope.make());
298 return {
299 runtime,
300 clientScope,
301 clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)),
302 };
303 }
304
305 private logWarning(message: string, metadata: { readonly error: string }) {
306 const logWarning = this.options?.logWarning;
307 if (logWarning) {
308 logWarning(message, metadata);
309 } else {
310 Effect.runSync(Effect.logWarning(message, metadata));
311 }
312 }
313
314 private runStreamOnSession<TValue>(
315 session: TransportSession,
316 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
317 listener: (value: TValue) => void,
318 isActive: () => boolean,
319 markValueReceived: () => void,
320 ): {
321 readonly cancel: () => void;
322 readonly completed: Promise<void>;
323 } {
324 let resolveCompleted!: () => void;
325 let rejectCompleted!: (error: unknown) => void;
326 const completed = new Promise<void>((resolve, reject) => {
327 resolveCompleted = resolve;
328 rejectCompleted = reject;
329 });
330 const cancel = session.runtime.runCallback(
331 Effect.promise(() => session.clientPromise).pipe(
332 Effect.flatMap((client) =>
333 Stream.runForEach(connect(client), (value) =>
334 Effect.sync(() => {
335 if (!isActive()) {
336 return;
337 }
338
339 markValueReceived();
340 try {
341 listener(value);
342 } catch {
343 // Ignore listener errors so the stream stays live.
344 }
345 }),
346 ),
347 ),
348 ),
349 {
350 onExit: (exit) => {
351 if (Exit.isSuccess(exit)) {
352 resolveCompleted();
353 return;
354 }
355
356 rejectCompleted(Cause.squash(exit.cause));
357 },
358 },
359 );
360
361 return {
362 cancel,
363 completed,
364 };
365 }
366}
367
368function sleep(ms: number): Promise<void> {
369 return Effect.runPromise(Effect.sleep(Duration.millis(ms)));
370}
packages/client-runtime/vitest.config.ts created+8
...@@ -0,0 +1,8 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 environment: "node",
7 },
8});
packages/contracts/src/index.ts+1
...@@ -19,4 +19,5 @@ export * from "./orchestration.ts";...@@ -19,4 +19,5 @@ export * from "./orchestration.ts";
19export * from "./editor.ts";19export * from "./editor.ts";
20export * from "./project.ts";20export * from "./project.ts";
21export * from "./filesystem.ts";21export * from "./filesystem.ts";
22export * from "./review.ts";
22export * from "./rpc.ts";23export * from "./rpc.ts";
packages/contracts/src/ipc.ts+26-8
...@@ -1,10 +1,6 @@...@@ -1,10 +1,6 @@
1import type {1import type {
2 VcsSwitchRefInput,
3 VcsSwitchRefResult,
4 VcsCreateRefInput,2 VcsCreateRefInput,
5 GitPreparePullRequestThreadInput,3 VcsCreateRefResult,
6 GitPreparePullRequestThreadResult,
7 GitPullRequestRefInput,
8 VcsCreateWorktreeInput,4 VcsCreateWorktreeInput,
9 VcsCreateWorktreeResult,5 VcsCreateWorktreeResult,
10 VcsInitInput,6 VcsInitInput,
...@@ -13,11 +9,16 @@ import type {...@@ -13,11 +9,16 @@ import type {
13 VcsPullInput,9 VcsPullInput,
14 VcsPullResult,10 VcsPullResult,
15 VcsRemoveWorktreeInput,11 VcsRemoveWorktreeInput,
12 VcsSwitchRefInput,
13 VcsSwitchRefResult,
14 GitPreparePullRequestThreadInput,
15 GitPreparePullRequestThreadResult,
16 GitPullRequestRefInput,
16 GitResolvePullRequestResult,17 GitResolvePullRequestResult,
17 VcsStatusInput,18 VcsStatusInput,
18 VcsStatusResult,19 VcsStatusResult,
19 VcsCreateRefResult,
20} from "./git.ts";20} from "./git.ts";
21import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts";
21import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";22import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
22import type {23import type {
23 ProjectSearchEntriesInput,24 ProjectSearchEntriesInput,
...@@ -40,9 +41,11 @@ import type {...@@ -40,9 +41,11 @@ import type {
40 ServerUpsertKeybindingResult,41 ServerUpsertKeybindingResult,
41} from "./server.ts";42} from "./server.ts";
42import type {43import type {
44 TerminalAttachInput,
45 TerminalAttachStreamEvent,
43 TerminalClearInput,46 TerminalClearInput,
44 TerminalCloseInput,47 TerminalCloseInput,
45 TerminalEvent,48 TerminalMetadataStreamEvent,
46 TerminalOpenInput,49 TerminalOpenInput,
47 TerminalResizeInput,50 TerminalResizeInput,
48 TerminalRestartInput,51 TerminalRestartInput,
...@@ -496,12 +499,24 @@ export interface LocalApi {...@@ -496,12 +499,24 @@ export interface LocalApi {
496export interface EnvironmentApi {499export interface EnvironmentApi {
497 terminal: {500 terminal: {
498 open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>;501 open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>;
502 attach: (
503 input: typeof TerminalAttachInput.Encoded,
504 callback: (event: TerminalAttachStreamEvent) => void,
505 options?: {
506 onResubscribe?: () => void;
507 },
508 ) => () => void;
499 write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>;509 write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>;
500 resize: (input: typeof TerminalResizeInput.Encoded) => Promise<void>;510 resize: (input: typeof TerminalResizeInput.Encoded) => Promise<void>;
501 clear: (input: typeof TerminalClearInput.Encoded) => Promise<void>;511 clear: (input: typeof TerminalClearInput.Encoded) => Promise<void>;
502 restart: (input: typeof TerminalRestartInput.Encoded) => Promise<TerminalSessionSnapshot>;512 restart: (input: typeof TerminalRestartInput.Encoded) => Promise<TerminalSessionSnapshot>;
503 close: (input: typeof TerminalCloseInput.Encoded) => Promise<void>;513 close: (input: typeof TerminalCloseInput.Encoded) => Promise<void>;
504 onEvent: (callback: (event: TerminalEvent) => void) => () => void;514 onMetadata: (
515 callback: (event: TerminalMetadataStreamEvent) => void,
516 options?: {
517 onResubscribe?: () => void;
518 },
519 ) => () => void;
505 };520 };
506 projects: {521 projects: {
507 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;522 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
...@@ -544,6 +559,9 @@ export interface EnvironmentApi {...@@ -544,6 +559,9 @@ export interface EnvironmentApi {
544 input: GitPreparePullRequestThreadInput,559 input: GitPreparePullRequestThreadInput,
545 ) => Promise<GitPreparePullRequestThreadResult>;560 ) => Promise<GitPreparePullRequestThreadResult>;
546 };561 };
562 review: {
563 getDiffPreview: (input: ReviewDiffPreviewInput) => Promise<ReviewDiffPreviewResult>;
564 };
547 orchestration: {565 orchestration: {
548 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;566 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
549 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;567 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
packages/contracts/src/review.ts created+35
...@@ -0,0 +1,35 @@
1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { GitCommandError } from "./git.ts";
4import { VcsError } from "./vcs.ts";
5
6export const ReviewDiffPreviewInput = Schema.Struct({
7 cwd: TrimmedNonEmptyString,
8 baseRef: Schema.optional(TrimmedNonEmptyString),
9});
10export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type;
11
12export const ReviewDiffPreviewSourceKind = Schema.Literals(["working-tree", "branch-range"]);
13export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type;
14
15export const ReviewDiffPreviewSource = Schema.Struct({
16 id: TrimmedNonEmptyString,
17 kind: ReviewDiffPreviewSourceKind,
18 title: TrimmedNonEmptyString,
19 baseRef: Schema.NullOr(TrimmedNonEmptyString),
20 headRef: Schema.NullOr(TrimmedNonEmptyString),
21 diff: Schema.String,
22 diffHash: TrimmedNonEmptyString,
23 truncated: Schema.Boolean,
24});
25export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type;
26
27export const ReviewDiffPreviewResult = Schema.Struct({
28 cwd: TrimmedNonEmptyString,
29 generatedAt: Schema.DateTimeUtc,
30 sources: Schema.Array(ReviewDiffPreviewSource),
31});
32export type ReviewDiffPreviewResult = typeof ReviewDiffPreviewResult.Type;
33
34export const ReviewDiffPreviewError = Schema.Union([VcsError, GitCommandError]);
35export type ReviewDiffPreviewError = typeof ReviewDiffPreviewError.Type;
packages/contracts/src/rpc.ts+40
...@@ -34,6 +34,11 @@ import {...@@ -34,6 +34,11 @@ import {
34 VcsStatusResult,34 VcsStatusResult,
35 VcsStatusStreamEvent,35 VcsStatusStreamEvent,
36} from "./git.ts";36} from "./git.ts";
37import {
38 ReviewDiffPreviewError,
39 ReviewDiffPreviewInput,
40 ReviewDiffPreviewResult,
41} from "./review.ts";
37import { KeybindingsConfigError } from "./keybindings.ts";42import { KeybindingsConfigError } from "./keybindings.ts";
38import {43import {
39 ClientOrchestrationCommand,44 ClientOrchestrationCommand,
...@@ -58,10 +63,13 @@ import {...@@ -58,10 +63,13 @@ import {
58 ProjectWriteFileResult,63 ProjectWriteFileResult,
59} from "./project.ts";64} from "./project.ts";
60import {65import {
66 TerminalAttachInput,
67 TerminalAttachStreamEvent,
61 TerminalClearInput,68 TerminalClearInput,
62 TerminalCloseInput,69 TerminalCloseInput,
63 TerminalError,70 TerminalError,
64 TerminalEvent,71 TerminalEvent,
72 TerminalMetadataStreamEvent,
65 TerminalOpenInput,73 TerminalOpenInput,
66 TerminalResizeInput,74 TerminalResizeInput,
67 TerminalRestartInput,75 TerminalRestartInput,
...@@ -128,8 +136,12 @@ export const WS_METHODS = {...@@ -128,8 +136,12 @@ export const WS_METHODS = {
128 gitResolvePullRequest: "git.resolvePullRequest",136 gitResolvePullRequest: "git.resolvePullRequest",
129 gitPreparePullRequestThread: "git.preparePullRequestThread",137 gitPreparePullRequestThread: "git.preparePullRequestThread",
130138
139 // Review methods
140 reviewGetDiffPreview: "review.getDiffPreview",
141
131 // Terminal methods142 // Terminal methods
132 terminalOpen: "terminal.open",143 terminalOpen: "terminal.open",
144 terminalAttach: "terminal.attach",
133 terminalWrite: "terminal.write",145 terminalWrite: "terminal.write",
134 terminalResize: "terminal.resize",146 terminalResize: "terminal.resize",
135 terminalClear: "terminal.clear",147 terminalClear: "terminal.clear",
...@@ -158,6 +170,7 @@ export const WS_METHODS = {...@@ -158,6 +170,7 @@ export const WS_METHODS = {
158 // Streaming subscriptions170 // Streaming subscriptions
159 subscribeVcsStatus: "subscribeVcsStatus",171 subscribeVcsStatus: "subscribeVcsStatus",
160 subscribeTerminalEvents: "subscribeTerminalEvents",172 subscribeTerminalEvents: "subscribeTerminalEvents",
173 subscribeTerminalMetadata: "subscribeTerminalMetadata",
161 subscribeServerConfig: "subscribeServerConfig",174 subscribeServerConfig: "subscribeServerConfig",
162 subscribeServerLifecycle: "subscribeServerLifecycle",175 subscribeServerLifecycle: "subscribeServerLifecycle",
163 subscribeAuthAccess: "subscribeAuthAccess",176 subscribeAuthAccess: "subscribeAuthAccess",
...@@ -359,12 +372,30 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {...@@ -359,12 +372,30 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {
359 error: VcsError,372 error: VcsError,
360});373});
361374
375/**
376 * Ephemeral live diff preview for compact/mobile surfaces.
377 * Not the persisted T3 Review model. Future review sessions should use
378 * review.open* + review.getSnapshot.
379 */
380export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, {
381 payload: ReviewDiffPreviewInput,
382 success: ReviewDiffPreviewResult,
383 error: ReviewDiffPreviewError,
384});
385
362export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {386export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
363 payload: TerminalOpenInput,387 payload: TerminalOpenInput,
364 success: TerminalSessionSnapshot,388 success: TerminalSessionSnapshot,
365 error: TerminalError,389 error: TerminalError,
366});390});
367391
392export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, {
393 payload: TerminalAttachInput,
394 success: TerminalAttachStreamEvent,
395 error: TerminalError,
396 stream: true,
397});
398
368export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {399export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {
369 payload: TerminalWriteInput,400 payload: TerminalWriteInput,
370 error: TerminalError,401 error: TerminalError,
...@@ -453,6 +484,12 @@ export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTermina...@@ -453,6 +484,12 @@ export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTermina
453 stream: true,484 stream: true,
454});485});
455486
487export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, {
488 payload: Schema.Struct({}),
489 success: TerminalMetadataStreamEvent,
490 stream: true,
491});
492
456export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {493export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {
457 payload: Schema.Struct({}),494 payload: Schema.Struct({}),
458 success: ServerConfigStreamEvent,495 success: ServerConfigStreamEvent,
...@@ -504,13 +541,16 @@ export const WsRpcGroup = RpcGroup.make(...@@ -504,13 +541,16 @@ export const WsRpcGroup = RpcGroup.make(
504 WsVcsCreateRefRpc,541 WsVcsCreateRefRpc,
505 WsVcsSwitchRefRpc,542 WsVcsSwitchRefRpc,
506 WsVcsInitRpc,543 WsVcsInitRpc,
544 WsReviewGetDiffPreviewRpc,
507 WsTerminalOpenRpc,545 WsTerminalOpenRpc,
546 WsTerminalAttachRpc,
508 WsTerminalWriteRpc,547 WsTerminalWriteRpc,
509 WsTerminalResizeRpc,548 WsTerminalResizeRpc,
510 WsTerminalClearRpc,549 WsTerminalClearRpc,
511 WsTerminalRestartRpc,550 WsTerminalRestartRpc,
512 WsTerminalCloseRpc,551 WsTerminalCloseRpc,
513 WsSubscribeTerminalEventsRpc,552 WsSubscribeTerminalEventsRpc,
553 WsSubscribeTerminalMetadataRpc,
514 WsSubscribeServerConfigRpc,554 WsSubscribeServerConfigRpc,
515 WsSubscribeServerLifecycleRpc,555 WsSubscribeServerLifecycleRpc,
516 WsSubscribeAuthAccessRpc,556 WsSubscribeAuthAccessRpc,
packages/contracts/src/terminal.test.ts+68-13
...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33
4import {4import {
5 DEFAULT_TERMINAL_ID,5 DEFAULT_TERMINAL_ID,
6 TerminalAttachInput,
6 TerminalClearInput,7 TerminalClearInput,
7 TerminalCloseInput,8 TerminalCloseInput,
8 TerminalEvent,9 TerminalEvent,
...@@ -31,6 +32,7 @@ describe("TerminalOpenInput", () => {...@@ -31,6 +32,7 @@ describe("TerminalOpenInput", () => {
31 expect(32 expect(
32 decodes(TerminalOpenInput, {33 decodes(TerminalOpenInput, {
33 threadId: "thread-1",34 threadId: "thread-1",
35 terminalId: DEFAULT_TERMINAL_ID,
34 cwd: "/tmp/project",36 cwd: "/tmp/project",
35 cols: 120,37 cols: 120,
36 rows: 40,38 rows: 40,
...@@ -42,6 +44,7 @@ describe("TerminalOpenInput", () => {...@@ -42,6 +44,7 @@ describe("TerminalOpenInput", () => {
42 expect(44 expect(
43 decodes(TerminalOpenInput, {45 decodes(TerminalOpenInput, {
44 threadId: "thread-1",46 threadId: "thread-1",
47 terminalId: DEFAULT_TERMINAL_ID,
45 cwd: "/tmp/project",48 cwd: "/tmp/project",
46 cols: 423,49 cols: 423,
47 rows: 40,50 rows: 40,
...@@ -53,6 +56,7 @@ describe("TerminalOpenInput", () => {...@@ -53,6 +56,7 @@ describe("TerminalOpenInput", () => {
53 expect(56 expect(
54 decodes(TerminalOpenInput, {57 decodes(TerminalOpenInput, {
55 threadId: "thread-1",58 threadId: "thread-1",
59 terminalId: DEFAULT_TERMINAL_ID,
56 cwd: "/tmp/project",60 cwd: "/tmp/project",
57 cols: 10,61 cols: 10,
58 rows: 0,62 rows: 0,
...@@ -60,19 +64,21 @@ describe("TerminalOpenInput", () => {...@@ -60,19 +64,21 @@ describe("TerminalOpenInput", () => {
60 ).toBe(false);64 ).toBe(false);
61 });65 });
6266
63 it("defaults terminalId when missing", () => {67 it("requires terminalId — the client must always pick an id", () => {
64 const parsed = decodeSync(TerminalOpenInput, {68 expect(
65 threadId: "thread-1",69 decodes(TerminalOpenInput, {
66 cwd: "/tmp/project",70 threadId: "thread-1",
67 cols: 100,71 cwd: "/tmp/project",
68 rows: 24,72 cols: 100,
69 });73 rows: 24,
70 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);74 }),
75 ).toBe(false);
71 });76 });
7277
73 it("accepts optional env overrides", () => {78 it("accepts optional env overrides", () => {
74 const parsed = decodeSync(TerminalOpenInput, {79 const parsed = decodeSync(TerminalOpenInput, {
75 threadId: "thread-1",80 threadId: "thread-1",
81 terminalId: DEFAULT_TERMINAL_ID,
76 cwd: "/tmp/project",82 cwd: "/tmp/project",
77 worktreePath: "/tmp/project/.t3/worktrees/feature-a",83 worktreePath: "/tmp/project/.t3/worktrees/feature-a",
78 cols: 100,84 cols: 100,
...@@ -104,11 +110,25 @@ describe("TerminalOpenInput", () => {...@@ -104,11 +110,25 @@ describe("TerminalOpenInput", () => {
104 });110 });
105});111});
106112
113describe("TerminalAttachInput", () => {
114 it("accepts explicit inactive-session restart intent", () => {
115 const parsed = decodeSync(TerminalAttachInput, {
116 threadId: "thread-1",
117 terminalId: DEFAULT_TERMINAL_ID,
118 cwd: "/tmp/project",
119 restartIfNotRunning: true,
120 });
121
122 expect(parsed.restartIfNotRunning).toBe(true);
123 });
124});
125
107describe("TerminalWriteInput", () => {126describe("TerminalWriteInput", () => {
108 it("accepts non-empty data", () => {127 it("accepts non-empty data", () => {
109 expect(128 expect(
110 decodes(TerminalWriteInput, {129 decodes(TerminalWriteInput, {
111 threadId: "thread-1",130 threadId: "thread-1",
131 terminalId: DEFAULT_TERMINAL_ID,
112 data: "echo hello\n",132 data: "echo hello\n",
113 }),133 }),
114 ).toBe(true);134 ).toBe(true);
...@@ -118,10 +138,20 @@ describe("TerminalWriteInput", () => {...@@ -118,10 +138,20 @@ describe("TerminalWriteInput", () => {
118 expect(138 expect(
119 decodes(TerminalWriteInput, {139 decodes(TerminalWriteInput, {
120 threadId: "thread-1",140 threadId: "thread-1",
141 terminalId: DEFAULT_TERMINAL_ID,
121 data: "",142 data: "",
122 }),143 }),
123 ).toBe(false);144 ).toBe(false);
124 });145 });
146
147 it("rejects missing terminalId", () => {
148 expect(
149 decodes(TerminalWriteInput, {
150 threadId: "thread-1",
151 data: "echo hello\n",
152 }),
153 ).toBe(false);
154 });
125});155});
126156
127describe("TerminalThreadInput", () => {157describe("TerminalThreadInput", () => {
...@@ -136,17 +166,33 @@ describe("TerminalResizeInput", () => {...@@ -136,17 +166,33 @@ describe("TerminalResizeInput", () => {
136 expect(166 expect(
137 decodes(TerminalResizeInput, {167 decodes(TerminalResizeInput, {
138 threadId: "thread-1",168 threadId: "thread-1",
169 terminalId: DEFAULT_TERMINAL_ID,
139 cols: 80,170 cols: 80,
140 rows: 24,171 rows: 24,
141 }),172 }),
142 ).toBe(true);173 ).toBe(true);
143 });174 });
175
176 it("rejects missing terminalId", () => {
177 expect(
178 decodes(TerminalResizeInput, {
179 threadId: "thread-1",
180 cols: 80,
181 rows: 24,
182 }),
183 ).toBe(false);
184 });
144});185});
145186
146describe("TerminalClearInput", () => {187describe("TerminalClearInput", () => {
147 it("defaults terminal id", () => {188 it("requires terminalId", () => {
189 expect(decodes(TerminalClearInput, { threadId: "thread-1" })).toBe(false);
190 });
191
192 it("accepts an explicit terminalId", () => {
148 const parsed = decodeSync(TerminalClearInput, {193 const parsed = decodeSync(TerminalClearInput, {
149 threadId: "thread-1",194 threadId: "thread-1",
195 terminalId: DEFAULT_TERMINAL_ID,
150 });196 });
151 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);197 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
152 });198 });
...@@ -178,6 +224,7 @@ describe("TerminalSessionSnapshot", () => {...@@ -178,6 +224,7 @@ describe("TerminalSessionSnapshot", () => {
178 history: "hello\n",224 history: "hello\n",
179 exitCode: null,225 exitCode: null,
180 exitSignal: null,226 exitSignal: null,
227 label: "Primary",
181 updatedAt: isoTimestamp,228 updatedAt: isoTimestamp,
182 }),229 }),
183 ).toBe(true);230 ).toBe(true);
...@@ -193,7 +240,6 @@ describe("TerminalEvent", () => {...@@ -193,7 +240,6 @@ describe("TerminalEvent", () => {
193 type: "output",240 type: "output",
194 threadId: "thread-1",241 threadId: "thread-1",
195 terminalId: DEFAULT_TERMINAL_ID,242 terminalId: DEFAULT_TERMINAL_ID,
196 createdAt: isoTimestamp,
197 data: "line\n",243 data: "line\n",
198 }),244 }),
199 ).toBe(true);245 ).toBe(true);
...@@ -205,21 +251,30 @@ describe("TerminalEvent", () => {...@@ -205,21 +251,30 @@ describe("TerminalEvent", () => {
205 type: "exited",251 type: "exited",
206 threadId: "thread-1",252 threadId: "thread-1",
207 terminalId: DEFAULT_TERMINAL_ID,253 terminalId: DEFAULT_TERMINAL_ID,
208 createdAt: isoTimestamp,
209 exitCode: 0,254 exitCode: 0,
210 exitSignal: null,255 exitSignal: null,
211 }),256 }),
212 ).toBe(true);257 ).toBe(true);
213 });258 });
214259
260 it("accepts closed events", () => {
261 expect(
262 decodes(TerminalEvent, {
263 type: "closed",
264 threadId: "thread-1",
265 terminalId: DEFAULT_TERMINAL_ID,
266 }),
267 ).toBe(true);
268 });
269
215 it("accepts activity events", () => {270 it("accepts activity events", () => {
216 expect(271 expect(
217 decodes(TerminalEvent, {272 decodes(TerminalEvent, {
218 type: "activity",273 type: "activity",
219 threadId: "thread-1",274 threadId: "thread-1",
220 terminalId: DEFAULT_TERMINAL_ID,275 terminalId: DEFAULT_TERMINAL_ID,
221 createdAt: isoTimestamp,
222 hasRunningSubprocess: true,276 hasRunningSubprocess: true,
277 label: "vim",
223 }),278 }),
224 ).toBe(true);279 ).toBe(true);
225 });280 });
...@@ -230,7 +285,6 @@ describe("TerminalEvent", () => {...@@ -230,7 +285,6 @@ describe("TerminalEvent", () => {
230 type: "started",285 type: "started",
231 threadId: "thread-1",286 threadId: "thread-1",
232 terminalId: DEFAULT_TERMINAL_ID,287 terminalId: DEFAULT_TERMINAL_ID,
233 createdAt: isoTimestamp,
234 snapshot: {288 snapshot: {
235 threadId: "thread-1",289 threadId: "thread-1",
236 terminalId: DEFAULT_TERMINAL_ID,290 terminalId: DEFAULT_TERMINAL_ID,
...@@ -241,6 +295,7 @@ describe("TerminalEvent", () => {...@@ -241,6 +295,7 @@ describe("TerminalEvent", () => {
241 history: "",295 history: "",
242 exitCode: null,296 exitCode: null,
243 exitSignal: null,297 exitSignal: null,
298 label: "Primary",
244 updatedAt: isoTimestamp,299 updatedAt: isoTimestamp,
245 },300 },
246 }),301 }),
packages/contracts/src/terminal.ts+86-8
...@@ -1,8 +1,12 @@...@@ -1,8 +1,12 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
3import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
43
5export const DEFAULT_TERMINAL_ID = "default";4/**
5 * Client-side id for the first shell opened on a thread. Ids are uniformly
6 * `term-N`; there's no "default" intrinsic. Kept as a named constant so callers
7 * that want "the primary shell" don't hardcode `"term-1"`.
8 */
9export const DEFAULT_TERMINAL_ID = "term-1";
610
7const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;11const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
8const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(12const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(
...@@ -20,18 +24,15 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc...@@ -20,18 +24,15 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc
20 Schema.isMaxProperties(128),24 Schema.isMaxProperties(128),
21);25);
2226
23const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
24 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_ID)),
25);
26
27export const TerminalThreadInput = Schema.Struct({27export const TerminalThreadInput = Schema.Struct({
28 threadId: TrimmedNonEmptyStringSchema,28 threadId: TrimmedNonEmptyStringSchema,
29});29});
30export type TerminalThreadInput = typeof TerminalThreadInput.Type;30export type TerminalThreadInput = typeof TerminalThreadInput.Type;
3131
32/** Terminal ids are ALWAYS chosen by the client and sent explicitly — no server-side allocation. */
32const TerminalSessionInput = Schema.Struct({33const TerminalSessionInput = Schema.Struct({
33 ...TerminalThreadInput.fields,34 ...TerminalThreadInput.fields,
34 terminalId: TerminalIdWithDefaultSchema,35 terminalId: TerminalIdSchema,
35});36});
36export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;37export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;
3738
...@@ -45,6 +46,17 @@ export const TerminalOpenInput = Schema.Struct({...@@ -45,6 +46,17 @@ export const TerminalOpenInput = Schema.Struct({
45});46});
46export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>;47export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>;
4748
49export const TerminalAttachInput = Schema.Struct({
50 ...TerminalSessionInput.fields,
51 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
52 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
53 cols: Schema.optional(TerminalColsSchema),
54 rows: Schema.optional(TerminalRowsSchema),
55 env: Schema.optional(TerminalEnvSchema),
56 restartIfNotRunning: Schema.optional(Schema.Boolean),
57});
58export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>;
59
48export const TerminalWriteInput = Schema.Struct({60export const TerminalWriteInput = Schema.Struct({
49 ...TerminalSessionInput.fields,61 ...TerminalSessionInput.fields,
50 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),62 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),
...@@ -91,14 +103,56 @@ export const TerminalSessionSnapshot = Schema.Struct({...@@ -91,14 +103,56 @@ export const TerminalSessionSnapshot = Schema.Struct({
91 history: Schema.String,103 history: Schema.String,
92 exitCode: Schema.NullOr(Schema.Int),104 exitCode: Schema.NullOr(Schema.Int),
93 exitSignal: Schema.NullOr(Schema.Int),105 exitSignal: Schema.NullOr(Schema.Int),
106 /** Server-computed display title (idle shell vs subprocess command). */
107 label: Schema.String.check(Schema.isMaxLength(128)),
94 updatedAt: Schema.String,108 updatedAt: Schema.String,
109 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
95});110});
96export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;111export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;
97112
113export const TerminalSummary = Schema.Struct({
114 threadId: Schema.String.check(Schema.isNonEmpty()),
115 terminalId: Schema.String.check(Schema.isNonEmpty()),
116 cwd: Schema.String.check(Schema.isNonEmpty()),
117 worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema),
118 status: TerminalSessionStatus,
119 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
120 exitCode: Schema.NullOr(Schema.Int),
121 exitSignal: Schema.NullOr(Schema.Int),
122 hasRunningSubprocess: Schema.Boolean,
123 /** Server-computed display title (idle shell vs subprocess command). */
124 label: Schema.String.check(Schema.isMaxLength(128)),
125 updatedAt: Schema.String,
126});
127export type TerminalSummary = typeof TerminalSummary.Type;
128
129const TerminalMetadataSnapshotEvent = Schema.Struct({
130 type: Schema.Literal("snapshot"),
131 terminals: Schema.Array(TerminalSummary),
132});
133
134const TerminalMetadataUpsertEvent = Schema.Struct({
135 type: Schema.Literal("upsert"),
136 terminal: TerminalSummary,
137});
138
139const TerminalMetadataRemoveEvent = Schema.Struct({
140 type: Schema.Literal("remove"),
141 threadId: Schema.String.check(Schema.isNonEmpty()),
142 terminalId: Schema.String.check(Schema.isNonEmpty()),
143});
144
145export const TerminalMetadataStreamEvent = Schema.Union([
146 TerminalMetadataSnapshotEvent,
147 TerminalMetadataUpsertEvent,
148 TerminalMetadataRemoveEvent,
149]);
150export type TerminalMetadataStreamEvent = typeof TerminalMetadataStreamEvent.Type;
151
98const TerminalEventBaseSchema = Schema.Struct({152const TerminalEventBaseSchema = Schema.Struct({
99 threadId: Schema.String.check(Schema.isNonEmpty()),153 threadId: Schema.String.check(Schema.isNonEmpty()),
100 terminalId: Schema.String.check(Schema.isNonEmpty()),154 terminalId: Schema.String.check(Schema.isNonEmpty()),
101 createdAt: Schema.String,155 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
102});156});
103157
104const TerminalStartedEvent = Schema.Struct({158const TerminalStartedEvent = Schema.Struct({
...@@ -120,6 +174,11 @@ const TerminalExitedEvent = Schema.Struct({...@@ -120,6 +174,11 @@ const TerminalExitedEvent = Schema.Struct({
120 exitSignal: Schema.NullOr(Schema.Int),174 exitSignal: Schema.NullOr(Schema.Int),
121});175});
122176
177const TerminalClosedEvent = Schema.Struct({
178 ...TerminalEventBaseSchema.fields,
179 type: Schema.Literal("closed"),
180});
181
123const TerminalErrorEvent = Schema.Struct({182const TerminalErrorEvent = Schema.Struct({
124 ...TerminalEventBaseSchema.fields,183 ...TerminalEventBaseSchema.fields,
125 type: Schema.Literal("error"),184 type: Schema.Literal("error"),
...@@ -141,12 +200,14 @@ const TerminalActivityEvent = Schema.Struct({...@@ -141,12 +200,14 @@ const TerminalActivityEvent = Schema.Struct({
141 ...TerminalEventBaseSchema.fields,200 ...TerminalEventBaseSchema.fields,
142 type: Schema.Literal("activity"),201 type: Schema.Literal("activity"),
143 hasRunningSubprocess: Schema.Boolean,202 hasRunningSubprocess: Schema.Boolean,
203 label: Schema.String.check(Schema.isMaxLength(128)),
144});204});
145205
146export const TerminalEvent = Schema.Union([206export const TerminalEvent = Schema.Union([
147 TerminalStartedEvent,207 TerminalStartedEvent,
148 TerminalOutputEvent,208 TerminalOutputEvent,
149 TerminalExitedEvent,209 TerminalExitedEvent,
210 TerminalClosedEvent,
150 TerminalErrorEvent,211 TerminalErrorEvent,
151 TerminalClearedEvent,212 TerminalClearedEvent,
152 TerminalRestartedEvent,213 TerminalRestartedEvent,
...@@ -154,6 +215,23 @@ export const TerminalEvent = Schema.Union([...@@ -154,6 +215,23 @@ export const TerminalEvent = Schema.Union([
154]);215]);
155export type TerminalEvent = typeof TerminalEvent.Type;216export type TerminalEvent = typeof TerminalEvent.Type;
156217
218const TerminalAttachSnapshotEvent = Schema.Struct({
219 type: Schema.Literal("snapshot"),
220 snapshot: TerminalSessionSnapshot,
221});
222
223export const TerminalAttachStreamEvent = Schema.Union([
224 TerminalAttachSnapshotEvent,
225 TerminalOutputEvent,
226 TerminalExitedEvent,
227 TerminalClosedEvent,
228 TerminalErrorEvent,
229 TerminalClearedEvent,
230 TerminalRestartedEvent,
231 TerminalActivityEvent,
232]);
233export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type;
234
157export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()(235export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()(
158 "TerminalCwdError",236 "TerminalCwdError",
159 {237 {
packages/shared/package.json+16
...@@ -71,6 +71,14 @@...@@ -71,6 +71,14 @@
71 "types": "./src/projectScripts.ts",71 "types": "./src/projectScripts.ts",
72 "import": "./src/projectScripts.ts"72 "import": "./src/projectScripts.ts"
73 },73 },
74 "./orchestrationTiming": {
75 "types": "./src/orchestrationTiming.ts",
76 "import": "./src/orchestrationTiming.ts"
77 },
78 "./remote": {
79 "types": "./src/remote.ts",
80 "import": "./src/remote.ts"
81 },
74 "./searchRanking": {82 "./searchRanking": {
75 "types": "./src/searchRanking.ts",83 "types": "./src/searchRanking.ts",
76 "import": "./src/searchRanking.ts"84 "import": "./src/searchRanking.ts"
...@@ -90,6 +98,14 @@...@@ -90,6 +98,14 @@
90 "./keybindings": {98 "./keybindings": {
91 "types": "./src/keybindings.ts",99 "types": "./src/keybindings.ts",
92 "import": "./src/keybindings.ts"100 "import": "./src/keybindings.ts"
101 },
102 "./composerTrigger": {
103 "types": "./src/composerTrigger.ts",
104 "import": "./src/composerTrigger.ts"
105 },
106 "./terminalLabels": {
107 "types": "./src/terminalLabels.ts",
108 "import": "./src/terminalLabels.ts"
93 }109 }
94 },110 },
95 "scripts": {111 "scripts": {
packages/shared/src/composerTrigger.ts created+117
...@@ -0,0 +1,117 @@
1export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill";
2export type ComposerSlashCommand = "model" | "plan" | "default";
3
4export interface ComposerTrigger {
5 kind: ComposerTriggerKind;
6 query: string;
7 rangeStart: number;
8 rangeEnd: number;
9}
10
11function clampCursor(text: string, cursor: number): number {
12 if (!Number.isFinite(cursor)) return text.length;
13 return Math.max(0, Math.min(text.length, Math.floor(cursor)));
14}
15
16function isWhitespace(char: string): boolean {
17 return char === " " || char === "\n" || char === "\t" || char === "\r";
18}
19
20/**
21 * Detect an active trigger (@path, $skill, /command) at the cursor position.
22 *
23 * Accepts an optional `isWhitespaceChar` override so callers with inline
24 * placeholder characters (e.g. terminal context chips on web) can treat
25 * those as token boundaries.
26 */
27export function detectComposerTrigger(
28 text: string,
29 cursorInput: number,
30 isWhitespaceChar?: (char: string) => boolean,
31): ComposerTrigger | null {
32 const cursor = clampCursor(text, cursorInput);
33 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
34 const linePrefix = text.slice(lineStart, cursor);
35
36 if (linePrefix.startsWith("/")) {
37 const commandMatch = /^\/(\S*)$/.exec(linePrefix);
38 if (commandMatch) {
39 const commandQuery = commandMatch[1] ?? "";
40 if (commandQuery.toLowerCase() === "model") {
41 return {
42 kind: "slash-model",
43 query: "",
44 rangeStart: lineStart,
45 rangeEnd: cursor,
46 };
47 }
48 return {
49 kind: "slash-command",
50 query: commandQuery,
51 rangeStart: lineStart,
52 rangeEnd: cursor,
53 };
54 }
55
56 const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix);
57 if (modelMatch) {
58 return {
59 kind: "slash-model",
60 query: (modelMatch[1] ?? "").trim(),
61 rangeStart: lineStart,
62 rangeEnd: cursor,
63 };
64 }
65 }
66
67 const wsCheck = isWhitespaceChar ?? isWhitespace;
68 let tokenIdx = cursor - 1;
69 while (tokenIdx >= 0 && !wsCheck(text[tokenIdx] ?? "")) {
70 tokenIdx -= 1;
71 }
72 const tokenStart = tokenIdx + 1;
73
74 const token = text.slice(tokenStart, cursor);
75 if (token.startsWith("$")) {
76 return {
77 kind: "skill",
78 query: token.slice(1),
79 rangeStart: tokenStart,
80 rangeEnd: cursor,
81 };
82 }
83 if (!token.startsWith("@")) {
84 return null;
85 }
86
87 return {
88 kind: "path",
89 query: token.slice(1),
90 rangeStart: tokenStart,
91 rangeEnd: cursor,
92 };
93}
94
95export function parseStandaloneComposerSlashCommand(
96 text: string,
97): Exclude<ComposerSlashCommand, "model"> | null {
98 const match = /^\/(plan|default)\s*$/i.exec(text.trim());
99 if (!match) {
100 return null;
101 }
102 const command = match[1]?.toLowerCase();
103 if (command === "plan") return "plan";
104 return "default";
105}
106
107export function replaceTextRange(
108 text: string,
109 rangeStart: number,
110 rangeEnd: number,
111 replacement: string,
112): { text: string; cursor: number } {
113 const safeStart = Math.max(0, Math.min(text.length, rangeStart));
114 const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd));
115 const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`;
116 return { text: nextText, cursor: safeStart + replacement.length };
117}
packages/shared/src/orchestrationTiming.ts created+54
...@@ -0,0 +1,54 @@
1type LatestTurnTiming = {
2 readonly turnId: string | null;
3 readonly startedAt: string | null;
4 readonly completedAt: string | null;
5};
6
7type SessionActivityState = {
8 readonly orchestrationStatus: string;
9 readonly activeTurnId?: string | null;
10};
11
12export function formatDuration(durationMs: number): string {
13 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
14 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
15 if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
16 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
17 const minutes = Math.floor(durationMs / 60_000);
18 const seconds = Math.round((durationMs % 60_000) / 1_000);
19 if (seconds === 0) return `${minutes}m`;
20 if (seconds === 60) return `${minutes + 1}m`;
21 return `${minutes}m ${seconds}s`;
22}
23
24export function formatElapsed(startIso: string, endIso: string | undefined): string | null {
25 if (!endIso) return null;
26 const startedAt = Date.parse(startIso);
27 const endedAt = Date.parse(endIso);
28 if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {
29 return null;
30 }
31 return formatDuration(endedAt - startedAt);
32}
33
34export function isLatestTurnSettled(
35 latestTurn: LatestTurnTiming | null,
36 session: SessionActivityState | null,
37): boolean {
38 if (!latestTurn?.startedAt) return false;
39 if (!latestTurn.completedAt) return false;
40 if (!session) return true;
41 if (session.orchestrationStatus === "running") return false;
42 return true;
43}
44
45export function deriveActiveWorkStartedAt(
46 latestTurn: LatestTurnTiming | null,
47 session: SessionActivityState | null,
48 sendStartedAt: string | null,
49): string | null {
50 if (!isLatestTurnSettled(latestTurn, session)) {
51 return latestTurn?.startedAt ?? sendStartedAt;
52 }
53 return sendStartedAt;
54}
packages/shared/src/remote.test.ts created+68
...@@ -0,0 +1,68 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveRemotePairingTarget } from "./remote.ts";
4
5describe("remote", () => {
6 it("derives backend urls and token from a pairing url", () => {
7 expect(
8 resolveRemotePairingTarget({
9 pairingUrl: "https://remote.example.com/pair#token=pairing-token",
10 }),
11 ).toEqual({
12 credential: "pairing-token",
13 httpBaseUrl: "https://remote.example.com/",
14 wsBaseUrl: "wss://remote.example.com/",
15 });
16 });
17
18 it("accepts pairing urls that still use a query token", () => {
19 expect(
20 resolveRemotePairingTarget({
21 pairingUrl: "https://remote.example.com/pair?token=pairing-token",
22 }),
23 ).toEqual({
24 credential: "pairing-token",
25 httpBaseUrl: "https://remote.example.com/",
26 wsBaseUrl: "wss://remote.example.com/",
27 });
28 });
29
30 it("derives backend urls from hosted app pairing links", () => {
31 expect(
32 resolveRemotePairingTarget({
33 pairingUrl:
34 "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%3A44342%2F#token=pairing-token",
35 }),
36 ).toEqual({
37 credential: "pairing-token",
38 httpBaseUrl: "https://desktop.tailnet.ts.net:44342/",
39 wsBaseUrl: "wss://desktop.tailnet.ts.net:44342/",
40 });
41 });
42
43 it("derives backend urls from a host and pairing code", () => {
44 expect(
45 resolveRemotePairingTarget({
46 host: "https://remote.example.com",
47 pairingCode: "pairing-token",
48 }),
49 ).toEqual({
50 credential: "pairing-token",
51 httpBaseUrl: "https://remote.example.com/",
52 wsBaseUrl: "wss://remote.example.com/",
53 });
54 });
55
56 it("preserves host ports when normalizing a bare host input", () => {
57 expect(
58 resolveRemotePairingTarget({
59 host: "myserver.com:3000",
60 pairingCode: "pairing-token",
61 }),
62 ).toEqual({
63 credential: "pairing-token",
64 httpBaseUrl: "https://myserver.com:3000/",
65 wsBaseUrl: "wss://myserver.com:3000/",
66 });
67 });
68});
packages/shared/src/remote.ts created+151
...@@ -0,0 +1,151 @@
1const PAIRING_TOKEN_PARAM = "token";
2const HOSTED_PAIRING_HOST_PARAM = "host";
3const HOSTED_PAIRING_LABEL_PARAM = "label";
4
5const readHashParams = (url: URL): URLSearchParams =>
6 new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
7
8const normalizeRemoteBaseUrl = (rawValue: string): URL => {
9 const trimmed = rawValue.trim();
10 if (!trimmed) {
11 throw new Error("Enter a backend URL.");
12 }
13
14 const normalizedInput =
15 /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//")
16 ? trimmed
17 : `https://${trimmed}`;
18 const url = new URL(normalizedInput);
19 url.pathname = "/";
20 url.search = "";
21 url.hash = "";
22 return url;
23};
24
25const toHttpBaseUrl = (url: URL): string => {
26 const next = new URL(url.toString());
27 if (next.protocol === "ws:") {
28 next.protocol = "http:";
29 } else if (next.protocol === "wss:") {
30 next.protocol = "https:";
31 }
32 next.pathname = "/";
33 next.search = "";
34 next.hash = "";
35 return next.toString();
36};
37
38const toWsBaseUrl = (url: URL): string => {
39 const next = new URL(url.toString());
40 if (next.protocol === "http:") {
41 next.protocol = "ws:";
42 } else if (next.protocol === "https:") {
43 next.protocol = "wss:";
44 }
45 next.pathname = "/";
46 next.search = "";
47 next.hash = "";
48 return next.toString();
49};
50
51export interface ResolvedRemotePairingTarget {
52 readonly credential: string;
53 readonly httpBaseUrl: string;
54 readonly wsBaseUrl: string;
55}
56
57export interface HostedPairingRequest {
58 readonly host: string;
59 readonly token: string;
60 readonly label: string;
61}
62
63export const getPairingTokenFromUrl = (url: URL): string | null => {
64 const hashToken = readHashParams(url).get(PAIRING_TOKEN_PARAM)?.trim() ?? "";
65 if (hashToken.length > 0) {
66 return hashToken;
67 }
68
69 const searchToken = url.searchParams.get(PAIRING_TOKEN_PARAM)?.trim() ?? "";
70 return searchToken.length > 0 ? searchToken : null;
71};
72
73export const stripPairingTokenFromUrl = (url: URL): URL => {
74 const next = new URL(url.toString());
75 const hashParams = readHashParams(next);
76 if (hashParams.has(PAIRING_TOKEN_PARAM)) {
77 hashParams.delete(PAIRING_TOKEN_PARAM);
78 next.hash = hashParams.toString();
79 }
80 next.searchParams.delete(PAIRING_TOKEN_PARAM);
81 return next;
82};
83
84export const setPairingTokenOnUrl = (url: URL, credential: string): URL => {
85 const next = new URL(url.toString());
86 next.searchParams.delete(PAIRING_TOKEN_PARAM);
87 next.hash = new URLSearchParams([[PAIRING_TOKEN_PARAM, credential]]).toString();
88 return next;
89};
90
91export const readHostedPairingRequest = (url: URL): HostedPairingRequest | null => {
92 const host = url.searchParams.get(HOSTED_PAIRING_HOST_PARAM)?.trim() ?? "";
93 const token = getPairingTokenFromUrl(url)?.trim() ?? "";
94 const label = url.searchParams.get(HOSTED_PAIRING_LABEL_PARAM)?.trim() ?? "";
95
96 if (!host || !token) {
97 return null;
98 }
99
100 return {
101 host,
102 token,
103 label,
104 };
105};
106
107export const resolveRemotePairingTarget = (input: {
108 readonly pairingUrl?: string;
109 readonly host?: string;
110 readonly pairingCode?: string;
111}): ResolvedRemotePairingTarget => {
112 const pairingUrl = input.pairingUrl?.trim() ?? "";
113 if (pairingUrl.length > 0) {
114 const url = new URL(pairingUrl);
115 const hostedPairingRequest = readHostedPairingRequest(url);
116 if (hostedPairingRequest) {
117 const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host);
118 return {
119 credential: hostedPairingRequest.token,
120 httpBaseUrl: toHttpBaseUrl(hostedBackendUrl),
121 wsBaseUrl: toWsBaseUrl(hostedBackendUrl),
122 };
123 }
124
125 const credential = getPairingTokenFromUrl(url) ?? "";
126 if (!credential) {
127 throw new Error("Pairing URL is missing its token.");
128 }
129 return {
130 credential,
131 httpBaseUrl: toHttpBaseUrl(url),
132 wsBaseUrl: toWsBaseUrl(url),
133 };
134 }
135
136 const host = input.host?.trim() ?? "";
137 const pairingCode = input.pairingCode?.trim() ?? "";
138 if (!host) {
139 throw new Error("Enter a backend URL.");
140 }
141 if (!pairingCode) {
142 throw new Error("Enter a pairing code.");
143 }
144
145 const normalizedHost = normalizeRemoteBaseUrl(host);
146 return {
147 credential: pairingCode,
148 httpBaseUrl: toHttpBaseUrl(normalizedHost),
149 wsBaseUrl: toWsBaseUrl(normalizedHost),
150 };
151};
packages/shared/src/terminalLabels.test.ts created+54
...@@ -0,0 +1,54 @@
1import { describe, expect, it } from "vitest";
2
3import type { TerminalSummary } from "@t3tools/contracts";
4import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts";
5
6import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts";
7
8describe("getTerminalLabel", () => {
9 it("uses the numeric suffix for term-* ids", () => {
10 expect(getTerminalLabel(DEFAULT_TERMINAL_ID)).toBe("Terminal 1");
11 expect(getTerminalLabel("term-2")).toBe("Terminal 2");
12 expect(getTerminalLabel("term-12")).toBe("Terminal 12");
13 expect(getTerminalLabel("terminal-3")).toBe("Terminal 3");
14 });
15
16 it("falls back to the raw id for unknown shapes", () => {
17 expect(getTerminalLabel("custom-session")).toBe("custom-session");
18 });
19});
20
21describe("resolveTerminalSessionLabel", () => {
22 it("prefers a non-empty summary label", () => {
23 const summary = { label: " bun " } as Pick<TerminalSummary, "label">;
24 expect(resolveTerminalSessionLabel("term-1", summary)).toBe("bun");
25 });
26
27 it("falls back to getTerminalLabel when summary is missing or blank", () => {
28 expect(resolveTerminalSessionLabel(DEFAULT_TERMINAL_ID, { label: " " })).toBe("Terminal 1");
29 expect(resolveTerminalSessionLabel(DEFAULT_TERMINAL_ID, null)).toBe("Terminal 1");
30 expect(resolveTerminalSessionLabel("term-2", undefined)).toBe("Terminal 2");
31 });
32});
33
34describe("nextTerminalId", () => {
35 it("allocates term-1 when no terminals are listed yet", () => {
36 expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID);
37 expect(nextTerminalId([])).toBe("term-1");
38 });
39
40 it("allocates term-2 when only term-1 exists", () => {
41 expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2");
42 });
43
44 it("skips over taken term-N slots", () => {
45 expect(nextTerminalId([DEFAULT_TERMINAL_ID, "term-2", "term-3"])).toBe("term-4");
46 expect(nextTerminalId([DEFAULT_TERMINAL_ID, "term-3"])).toBe("term-2");
47 expect(nextTerminalId(["term-2", "term-3"])).toBe("term-1");
48 });
49
50 it("ignores blank/whitespace-only ids", () => {
51 expect(nextTerminalId(["", " ", DEFAULT_TERMINAL_ID])).toBe("term-2");
52 expect(nextTerminalId(["", " "])).toBe("term-1");
53 });
54});
packages/shared/src/terminalLabels.ts created+40
...@@ -0,0 +1,40 @@
1import type { TerminalSummary } from "@t3tools/contracts";
2
3/** Human-readable label for a terminal tab; matches mobile and web sidebars. */
4export function getTerminalLabel(terminalId: string): string {
5 const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1];
6 if (numericSuffix) {
7 return `Terminal ${numericSuffix}`;
8 }
9
10 return terminalId;
11}
12
13/** Prefer server summary label when present; otherwise fall back to `getTerminalLabel`. */
14export function resolveTerminalSessionLabel(
15 terminalId: string,
16 summary: Pick<TerminalSummary, "label"> | null | undefined,
17): string {
18 const trimmed = summary?.label?.trim();
19 if (trimmed && trimmed.length > 0) {
20 return trimmed;
21 }
22 return getTerminalLabel(terminalId);
23}
24
25/**
26 * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly
27 * on every `terminal.open` / `terminal.attach` call — the server never allocates.
28 *
29 * Returns the lowest unused `term-N` id (starting at `term-1`), skipping any ids already in
30 * `existingTerminalIds`.
31 */
32export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string {
33 const usedIds = new Set(existingTerminalIds.filter((id) => id.trim().length > 0));
34 let nextIndex = 1;
35 while (usedIds.has(`term-${nextIndex}`)) {
36 nextIndex += 1;
37 }
38
39 return `term-${nextIndex}`;
40}
patches/@pierre%2Fdiffs@1.1.20.patch created+23
...@@ -0,0 +1,23 @@
1diff --git a/package.json b/package.json
2index c0e762b13b56247b256520754e439cbc16bb797d..d298b87c175ec891d1b50f681c9031f4b1929e67 100644
3--- a/package.json
4+++ b/package.json
5@@ -51,6 +51,18 @@
6 "./worker/worker-portable.js": {
7 "types": "./dist/worker/worker-portable.d.ts",
8 "import": "./dist/worker/worker-portable.js"
9+ },
10+ "./types": {
11+ "types": "./dist/types.d.ts",
12+ "import": "./dist/types.js"
13+ },
14+ "./utils/getFiletypeFromFileName": {
15+ "types": "./dist/utils/getFiletypeFromFileName.d.ts",
16+ "import": "./dist/utils/getFiletypeFromFileName.js"
17+ },
18+ "./utils/parsePatchFiles": {
19+ "types": "./dist/utils/parsePatchFiles.d.ts",
20+ "import": "./dist/utils/parsePatchFiles.js"
21 }
22 },
23 "scripts": {
patches/react-native-nitro-modules@0.35.4.patch created+16
...@@ -0,0 +1,16 @@
1diff --git a/package.json b/package.json
2index 4e4366fb9d05a6c250acfc62e79487475d8ac05b..200e4680d77cbb4f14d22eb986efde1275a0d3e8 100644
3--- a/package.json
4+++ b/package.json
5@@ -88,6 +88,11 @@
6 "jsSrcsDir": "./src",
7 "android": {
8 "javaPackageName": "com.margelo.nitro"
9+ },
10+ "ios": {
11+ "modulesProvider": {
12+ "NitroModules": "NativeNitroModules"
13+ }
14 }
15 },
16 "jest": {
scripts/mobile-native-static-check.ts created+208
...@@ -0,0 +1,208 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Console from "effect/Console";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
12import { Command } from "effect/unstable/cli";
13import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
14
15interface NativeStaticTool {
16 readonly command: string;
17 readonly installHint: string;
18}
19
20class NativeStaticCheckError extends Data.TaggedError("NativeStaticCheckError")<{
21 readonly message: string;
22}> {}
23
24const tools = [
25 {
26 command: "swiftlint",
27 installHint: "brew install swiftlint",
28 },
29 {
30 command: "ktlint",
31 installHint: "brew install ktlint",
32 },
33 {
34 command: "detekt",
35 installHint: "brew install detekt",
36 },
37] as const satisfies ReadonlyArray<NativeStaticTool>;
38
39const sourceExtensions = new Set([".swift", ".kt", ".kts"]);
40const excludedDirectories = new Set([
41 ".expo",
42 ".git",
43 "android",
44 "build",
45 "DerivedData",
46 "ios",
47 "node_modules",
48 "Pods",
49 "Vendor",
50]);
51
52const appRoot = Effect.service(Path.Path).pipe(
53 Effect.flatMap((path) => path.fromFileUrl(new URL("../apps/mobile", import.meta.url))),
54);
55
56const commandOutputOptions = {
57 stdout: "inherit",
58 stderr: "inherit",
59} as const;
60
61const commandExists = Effect.fn("commandExists")(function* (command: string) {
62 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
63 const lookupCommand =
64 process.platform === "win32"
65 ? ChildProcess.make("where", [command], {
66 stdout: "ignore",
67 stderr: "ignore",
68 })
69 : ChildProcess.make("/bin/sh", ["-c", `command -v ${command}`], {
70 stdout: "ignore",
71 stderr: "ignore",
72 });
73
74 return yield* spawner.spawn(lookupCommand).pipe(
75 Effect.flatMap((child) => child.exitCode),
76 Effect.map((exitCode) => exitCode === 0),
77 Effect.catch(() => Effect.succeed(false)),
78 );
79});
80
81const warnMissingTool = (tool: NativeStaticTool, checkName: string) =>
82 Effect.logWarning(
83 `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`,
84 );
85
86const runCommand = Effect.fn("runCommand")(function* (
87 command: string,
88 args: ReadonlyArray<string>,
89 cwd: string,
90) {
91 yield* Console.log(`$ ${[command, ...args].join(" ")}`);
92 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
93 const child = yield* spawner.spawn(
94 ChildProcess.make(command, [...args], {
95 cwd,
96 ...commandOutputOptions,
97 shell: process.platform === "win32",
98 }),
99 );
100 const exitCode = Number(yield* child.exitCode);
101
102 if (exitCode !== 0) {
103 return yield* new NativeStaticCheckError({
104 message: `Command exited with non-zero exit code (${exitCode})`,
105 });
106 }
107});
108
109function collectSources(
110 directory: string,
111): Effect.Effect<
112 ReadonlyArray<string>,
113 PlatformError.PlatformError,
114 FileSystem.FileSystem | Path.Path
115> {
116 return Effect.gen(function* () {
117 const fs = yield* FileSystem.FileSystem;
118 const path = yield* Path.Path;
119 const entries = yield* fs.readDirectory(directory);
120 const sources: Array<string> = [];
121
122 for (const entry of entries) {
123 if (excludedDirectories.has(entry)) {
124 continue;
125 }
126
127 const entryPath = path.join(directory, entry);
128 const stat = yield* fs.stat(entryPath);
129
130 if (stat.type === "Directory") {
131 sources.push(...(yield* collectSources(entryPath)));
132 continue;
133 }
134
135 if (stat.type === "File" && sourceExtensions.has(path.extname(entry))) {
136 sources.push(entryPath);
137 }
138 }
139
140 return sources;
141 });
142}
143
144const runNativeStaticChecks = Effect.fn("runNativeStaticChecks")(function* () {
145 const path = yield* Path.Path;
146 const root = yield* appRoot;
147 const sources = yield* collectSources(root);
148 const swiftSources = sources.filter((source) => path.extname(source) === ".swift");
149 const kotlinSources = sources.filter((source) => {
150 const extension = path.extname(source);
151 return extension === ".kt" || extension === ".kts";
152 });
153 const availableTools = new Map<string, boolean>();
154
155 for (const tool of tools) {
156 availableTools.set(tool.command, yield* commandExists(tool.command));
157 }
158
159 if (swiftSources.length > 0) {
160 if (availableTools.get("swiftlint")) {
161 yield* runCommand("swiftlint", ["lint", "--config", ".swiftlint.yml", "--strict"], root);
162 } else {
163 yield* warnMissingTool(tools[0], "SwiftLint");
164 }
165 }
166
167 if (kotlinSources.length > 0) {
168 const relativeKotlinSources = kotlinSources.map((source) => path.relative(root, source));
169
170 if (availableTools.get("ktlint")) {
171 yield* runCommand("ktlint", relativeKotlinSources, root);
172 } else {
173 yield* warnMissingTool(tools[1], "ktlint");
174 }
175
176 if (availableTools.get("detekt")) {
177 yield* runCommand(
178 "detekt",
179 [
180 "--config",
181 "detekt.yml",
182 "--input",
183 relativeKotlinSources.join(","),
184 "--build-upon-default-config",
185 ],
186 root,
187 );
188 } else {
189 yield* warnMissingTool(tools[2], "detekt");
190 }
191 }
192
193 yield* Console.log("Skipping generated native project folders: android/, ios/.");
194});
195
196export const mobileNativeStaticCheckCommand = Command.make("mobile-native-static-check", {}, () =>
197 runNativeStaticChecks(),
198).pipe(
199 Command.withDescription("Run mobile native static analysis when native tools are available."),
200);
201
202if (import.meta.main) {
203 Command.run(mobileNativeStaticCheckCommand, { version: "0.0.0" }).pipe(
204 Effect.scoped,
205 Effect.provide([Logger.layer([Logger.consolePretty()]), NodeServices.layer]),
206 NodeRuntime.runMain,
207 );
208}