Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andlucas c532f32227 fix(app): preserve short terminal runs between accented glyphs
Short ASCII runs split by fallback glyphs can wrap or ellipsize in Android
Text when their layout width equals their cell reservation. Give text the
same extra layout room already used for isolated glyphs, while ordinary
run boxes keep clipping paint at their original cell boundaries.

Co-authored-by: lucas <lucas@withvus.ai>
2026-10-24 16:40:41 +00:00
paseo-bot[bot] 31f9657679 Merge commit 'a25a8ac676855c3f379d4f7ac89c0e6e42db5493' into bot/terminal-spacing-0e346cd5 2026-10-24 16:30:49 +00:00
paseo-bot[bot]andDavin 72e3316f4f fix(opencode): show model details on v2 subagent rows (#7799)
The v2 child descriptor omitted the session's presentation facts, leaving
completed children with title-only rows. Reuse the existing subtitle
formatter for both live updates and reconciliation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Davin <128177533+Davnn1@users.noreply.github.com>
2026-10-24 13:58:06 +00:00
Okan YILDIRIM 566218d682 fix(claude): avoid probing file targets during history replay (#7789) 2026-10-24 11:58:30 +00:00
Okan YILDIRIM b8bb335c56 fix(app): close comboboxes when their retained panel becomes inactive (#7783)
Close open comboboxes through their existing handler and remove their
presentation before a retained chat can freeze its closing update.

The compact regression came from unmounting the sheet while its visibility
tracker survived in Combobox. Its non-closed phase blocked the next present().
Keep the tracker inside the sheet component so they unmount together, and
preserve the desktop closed-state guard in Combobox.

Cover draft and existing-chat model selectors and the compact tab switcher
through Settings, Back, and reopening. The desktop regressions also verify
Escape dismissal.
2026-10-24 07:25:30 +00:00
paseo-ai[bot] e01a38d943 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-24 06:13:55 +00:00
Lucas a25a8ac676 Merge branch 'main' into fix/native-terminal-cjk-cursor-drift 2026-10-01 13:10:41 +09:00
lucas c08657d22a Keep native terminal text on its cells so the cursor stays after Korean input
The native grid renderer draws each style run as one Text, so glyphs advance
by their font's natural width while the cursor sits on the cell grid.

- Hangul, Han and kana fall back to a system font narrower than two cells
  (10.38pt vs 14.67pt at size 12), so every wide glyph pulled the rest of the
  run left. Ten syllables left the cursor about six columns past the text.
  Each script's advance is now measured once and its segment is letter-spaced
  to exactly two cells per glyph, without extra views.
- Cell width is snapped to the pixel grid (7.225pt -> 7.333pt), so a 49
  column ASCII line also ended about 0.7 cell short of the cursor. ASCII text
  now gets the matching letter spacing.
- Other non-ASCII glyphs (symbols, emoji, jamo) have no known advance, so
  they get their own box and may spill into the next cell instead of
  shifting the run.
2026-10-01 11:20:42 +09:00
45 changed files with 979 additions and 476 deletions
-15
View File
@@ -1,20 +1,5 @@
# Changelog
## 0.13.2 - 2026-10-26
### Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
## 0.13.0 - 2026-10-24
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
-15
View File
@@ -1,20 +1,5 @@
# 更新日志
## 0.13.2 - 2026-10-26
### 修复
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### 修复
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
## 0.13.0 - 2026-10-24
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
+70 -37
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.2",
"version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.2",
"version": "0.13.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -30269,9 +30269,9 @@
}
},
"node_modules/react-native-reanimated": {
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.5.tgz",
"integrity": "sha512-xtJXZRZ1vkec1AIUVG02St2sbyZS5jc7227TlDu/HonqUYPeZAfiC4RI2jqL1dVMNBu1+7oBx/c9QkjF2kg6Lg==",
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.3.1",
@@ -30280,7 +30280,7 @@
"peerDependencies": {
"react": "*",
"react-native": "0.83 - 0.86",
"react-native-worklets": "0.10.x - 0.11.x"
"react-native-worklets": "0.10.x"
}
},
"node_modules/react-native-safe-area-context": {
@@ -30406,9 +30406,9 @@
}
},
"node_modules/react-native-worklets": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.4.tgz",
"integrity": "sha512-gbk0CxMrGywEp3SJAHO+2JVaCWtl4WyAYBIxLZy6Y113+mHHuq3FuT6iV+VcaBuJzuuN34mtdZ4j5JIlBDzmeA==",
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.27.1",
@@ -35695,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -35780,14 +35780,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.5",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.4",
"react-native-worklets": "0.10.1",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -37961,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38213,10 +38213,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38228,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.2",
"version": "0.13.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38472,7 +38472,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.2",
"version": "0.13.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -41012,6 +41012,39 @@
}
}
},
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native-worklets": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.13.0.tgz",
"integrity": "sha512-1OrhAeAKaZF4XrBa4YbPQbKzNOpjh52HDlId8e6ete1OCHNdAlrQHzG4gznJ225EAj3dgQXHViJvK51M9B52jQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/generator": "^7.27.1",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.28.6",
"@babel/plugin-transform-classes": "^7.28.6",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
"@babel/plugin-transform-optional-chaining": "^7.28.6",
"@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.28.5",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"semver": "^7.7.4",
"whatwg-fetch": "^3.6.20"
},
"peerDependencies": {
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "0.86 - 0.88"
}
},
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -42091,7 +42124,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42120,15 +42153,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42138,8 +42171,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42161,7 +42194,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42188,7 +42221,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -42409,16 +42442,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43339,7 +43372,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44949,7 +44982,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.13.2",
"version": "0.13.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.2",
"version": "0.13.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -154,7 +154,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.5",
"react-native-worklets": "0.10.4"
"react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.1"
}
}
@@ -43,6 +43,8 @@ import {
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -53,6 +55,74 @@ async function openWorkspace(
}
test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
page,
}, testInfo) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-existing-model-",
title: "Settings model selector",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
});
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -147,6 +217,42 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
title: "Compact tab switcher",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
await switcher.click();
await expect(newAgent).toBeInViewport();
// Navigate without dismissing the sheet through its backdrop first.
await page
.getByTestId("menu-button")
.filter({ visible: true })
.first()
.dispatchEvent("click");
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
await expectAppRoute(page, buildSettingsRoute());
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
await expect(newAgent).toBeInViewport();
} finally {
await workspace.cleanup();
}
});
test("opens compact app and host details and returns through the settings list", async ({
page,
}) => {
+3 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.2",
"version": "0.13.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -126,14 +126,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.5",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.4",
"react-native-worklets": "0.10.1",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -198,14 +198,6 @@
],
"enableVideo": false
},
"expo": {
"install": {
"exclude": [
"react-native-reanimated",
"react-native-worklets"
]
}
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
@@ -2,7 +2,6 @@
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
@@ -487,20 +486,6 @@ describe("sidebar group visibility", () => {
},
);
it("treats placements without a hydrated entry as Done", () => {
const items = entries([...Array(8).fill("done"), "running", "done"]);
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
for (const entriesByKey of [new Map(), hydrated]) {
const { result } = renderHook(() =>
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
);
expect(result.current.canToggle).toBe(true);
expect(names(result.current.visibleItems)).toEqual(
entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"],
);
}
});
it("shows eight active rows once with no redundant toggle", () => {
const items = entries([
"running",
@@ -99,7 +99,6 @@ import {
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import {
SidebarWorkspaceRowFrame,
@@ -1594,7 +1593,11 @@ function ProjectBlock({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const visibilityPolicy = useMemo(
() => createWorkspaceVisibilityPolicy<SidebarWorkspacePlacement>(workspaceEntriesByKey),
() => ({
kind: "workspaces" as const,
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
}),
[workspaceEntriesByKey],
);
const {
@@ -4,18 +4,6 @@ export type SidebarGroupVisibilityPolicy<T> =
| { kind: "pinned" }
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the
// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders
// no row, so it counts as Done and claims no slot past the limit.
export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string }>(
entriesByKey: ReadonlyMap<string, { statusBucket: SidebarStateBucket }>,
): SidebarGroupVisibilityPolicy<T> {
return {
kind: "workspaces",
getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done",
};
}
export function selectSidebarGroupVisibility<T>(
items: readonly T[],
expanded: boolean,
+32 -23
View File
@@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -910,13 +911,9 @@ function resolveInitialActiveIndex(
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
}
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
interface MobileBodyProps {
bottomSheetRef: BottomSheetVisibility["sheetRef"];
isOpen: boolean;
snapPoints: string[];
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
handleIndicatorStyle: { backgroundColor: string };
titleColor: string;
title: string;
@@ -944,7 +941,22 @@ interface MobileBodyProps {
safeAreaBottom: number;
}
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
if (!active) return null;
// The visibility tracker must leave the tree with its sheet.
return <MobileComboboxSheet {...props} />;
}
function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: props.isOpen,
onClose: props.onClose,
});
const comboboxTitleStyle = useMemo(
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
@@ -979,13 +991,13 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
return (
<IsolatedBottomSheetModal
ref={props.bottomSheetRef}
ref={bottomSheetRef}
contextBridge={null}
snapPoints={props.snapPoints}
index={0}
enableDynamicSizing={false}
onChange={props.handleSheetChange}
onDismiss={props.handleSheetDismiss}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={ComboboxSheetBackground}
@@ -1168,7 +1180,8 @@ function DesktopComboboxOptionsBody(props: {
);
}
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
const handleDesktopKey = props.handleDesktopKey;
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
@@ -1190,6 +1203,9 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
[props.refs, setWebOverlayScope],
);
// Remove the portal before a retained chat can freeze its closing update.
if (!active) return null;
const overlay = (
<OverlayLayerProvider layer={props.overlayLayer}>
<View
@@ -1309,6 +1325,7 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const active = useRetainedPanelActive();
const floatingLayer = useOverlayLayer("floating");
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
@@ -1348,6 +1365,10 @@ export function Combobox({
bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (!active && isOpen) handleClose();
}, [active, isOpen, handleClose]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1413,16 +1434,6 @@ export function Combobox({
referenceWidth,
});
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: isOpen,
isEnabled: isMobile,
onClose: handleClose,
});
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption = useMemo(
@@ -1572,10 +1583,8 @@ export function Combobox({
if (isMobile) {
return (
<MobileComboboxBody
bottomSheetRef={bottomSheetRef}
isOpen={isOpen}
snapPoints={snapPoints}
handleSheetChange={handleSheetChange}
handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor}
title={resolvedTitle}
@@ -58,7 +58,6 @@ vi.mock("@gorhom/bottom-sheet", async () => {
});
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout";
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
/** Renders whatever the portal is currently holding, from its own place in the tree. */
@@ -201,35 +200,3 @@ describe("IsolatedBottomSheetModal context bridging", () => {
).toEqual(["false", "true"]);
});
});
describe("bottom sheet layout subscription", () => {
it("stops updating a dismissed sheet when the window resizes", () => {
const container = document.createElement("div");
const root = createRoot(container);
let layout: ReturnType<typeof useAnimatedLayout> | undefined;
const viewport = document.documentElement;
const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth");
function resize(width: number) {
Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width });
act(() => window.dispatchEvent(new Event("resize")));
}
function LayoutProbe() {
layout = useAnimatedLayout(undefined, 0, 0);
return null;
}
try {
act(() => root.render(<LayoutProbe />));
if (!layout) throw new Error("Sheet layout did not mount");
const mountedLayout = layout;
resize(640);
expect(mountedLayout.get().window.width).toBe(640);
act(() => root.render(null));
resize(960);
expect(mountedLayout.get().window.width).toBe(640);
} finally {
act(() => root.unmount());
if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth);
else Reflect.deleteProperty(viewport, "clientWidth");
}
});
});
@@ -5,6 +5,7 @@ import {
resolveTerminalGridMetricsMeasurement,
resolveMeasuredTerminalCellMetrics,
resolveTerminalCursorOffset,
resolveTerminalTextLetterSpacing,
} from "./terminal-grid-metrics";
describe("native terminal grid metrics", () => {
@@ -63,6 +64,39 @@ describe("native terminal grid metrics", () => {
});
});
it.each([1, 2, 2.75, 3, 3.5])(
"spaces font glyphs so text ends on the pixel-snapped cell grid at density %s",
(density) => {
const measurement = {
measuredTextWidth: 72.246,
measuredTextHeight: 16.2,
measureTextLength: 10,
roundToNearestPixel: (value: number) => Math.round(value * density) / density,
};
const { cellWidth } = resolveMeasuredTerminalCellMetrics(measurement);
const letterSpacing = resolveTerminalTextLetterSpacing({
...measurement,
cellWidth,
cellsPerGlyph: 1,
});
const glyphAdvance = measurement.measuredTextWidth / measurement.measureTextLength;
expect(80 * (glyphAdvance + letterSpacing)).toBeCloseTo(80 * cellWidth, 10);
},
);
it("spaces wide fallback glyphs to exactly two cells each", () => {
const cellWidth = 22 / 3;
const letterSpacing = resolveTerminalTextLetterSpacing({
measuredTextWidth: 103.8,
measureTextLength: 10,
cellWidth,
cellsPerGlyph: 2,
});
expect(24 * (10.38 + letterSpacing)).toBeCloseTo(48 * cellWidth, 10);
});
it("emits measured metrics once until the measurement changes", () => {
const measured = { cellWidth: 7.2, cellHeight: 16 };
@@ -44,6 +44,21 @@ export function resolveMeasuredTerminalCellMetrics(
};
}
export interface TerminalTextLetterSpacingInput {
measuredTextWidth: number;
measureTextLength: number;
cellWidth: number;
cellsPerGlyph: 1 | 2;
}
// Cells are snapped to the pixel grid but glyphs advance by their font's natural width, so a run
// of N glyphs ends N * (cells * cellWidth - advance) away from its cells and the cursor. The
// letter spacing makes up that difference per glyph.
export function resolveTerminalTextLetterSpacing(input: TerminalTextLetterSpacingInput): number {
const textLength = Math.max(1, input.measureTextLength);
return input.cellsPerGlyph * input.cellWidth - input.measuredTextWidth / textLength;
}
export function resolveTerminalGridMetricsMeasurement(
previous: TerminalGridCellMetrics | null,
next: TerminalGridCellMetrics,
@@ -25,14 +25,18 @@ import {
resolveTerminalCustomGlyphCellTransform,
resolveTerminalGridMetricsMeasurement,
resolveTerminalCursorOffset,
resolveTerminalTextLetterSpacing,
type TerminalGridCellMetrics,
} from "./terminal-grid-metrics";
import {
buildRows,
TERMINAL_WIDE_GLYPH_CLASSES,
type TerminalCustomGlyphCell,
type TerminalCustomGlyphRun,
type TerminalRowModel,
type TerminalRun,
type TerminalTextRun,
type TerminalWideGlyphClass,
} from "./terminal-row-model";
import type { TerminalSelectionRange } from "./terminal-selection";
@@ -40,12 +44,17 @@ const MEASURE_TEXT = "mmmmmmmmmm";
const DEFAULT_FONT_SIZE = 12;
const INITIAL_CELL_WIDTH_RATIO = 0.62;
const INITIAL_CELL_HEIGHT_RATIO = 1.35;
// Keep native line layout clear of the cell boundary: rounding and trailing letter spacing can
// otherwise wrap a short run. The run box clips ordinary text; isolated fallback glyphs can spill.
const TEXT_LAYOUT_OVERFLOW_CELLS = 2;
interface CellMetrics {
cellWidth: number;
cellHeight: number;
}
type TerminalWideGlyphStyles = Readonly<Record<TerminalWideGlyphClass, TextStyle>>;
interface TerminalGridViewport {
width: number;
height: number;
@@ -57,6 +66,8 @@ interface TerminalGridRowProps {
cellHeight: number;
fontFamily?: string;
fontSize: number;
letterSpacing: number;
wideGlyphStyles: TerminalWideGlyphStyles;
styleEpoch: string;
}
@@ -65,6 +76,7 @@ interface TerminalGridRunProps {
cellWidth: number;
cellHeight: number;
textStyle: StyleProp<TextStyle>;
wideGlyphStyles: TerminalWideGlyphStyles;
}
export interface TerminalGridViewProps {
@@ -99,21 +111,55 @@ function resolveVisibleCols(input: {
return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth)));
}
function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGridRunProps) {
function renderTerminalTextSegments(
run: TerminalTextRun,
wideGlyphStyles: TerminalWideGlyphStyles,
) {
if (!run.segments.some((segment) => segment.wideGlyphClass)) {
return run.text;
}
let offset = 0;
return run.segments.map((segment) => {
const key = `${offset}:${segment.wideGlyphClass ?? "font"}`;
offset += segment.text.length;
return (
<Text
key={key}
style={segment.wideGlyphClass ? wideGlyphStyles[segment.wideGlyphClass] : undefined}
>
{segment.text}
</Text>
);
});
}
function TerminalGridRun({
run,
cellWidth,
cellHeight,
textStyle,
wideGlyphStyles,
}: TerminalGridRunProps) {
const isolated = run.renderKind === "text" && run.isolated;
const runStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.run,
isolated ? styles.isolatedRun : styles.run,
{
backgroundColor: run.style.backgroundColor,
height: cellHeight,
width: run.cellCount * cellWidth,
},
],
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
[cellHeight, cellWidth, isolated, run.cellCount, run.style.backgroundColor],
);
const runTextStyle = useMemo<StyleProp<TextStyle>>(
() => [textStyle, run.style],
[run.style, textStyle],
() => [
textStyle,
run.style,
styles.runText,
{ width: (run.cellCount + TEXT_LAYOUT_OVERFLOW_CELLS) * cellWidth },
],
[cellWidth, run.cellCount, run.style, textStyle],
);
return (
@@ -121,8 +167,8 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
{run.renderKind === "custom-glyph" ? (
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
) : (
<Text numberOfLines={1} style={runTextStyle}>
{run.text}
<Text numberOfLines={1} ellipsizeMode="clip" style={runTextStyle}>
{renderTerminalTextSegments(run, wideGlyphStyles)}
</Text>
)}
</View>
@@ -225,7 +271,8 @@ const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => {
previous.run === next.run &&
previous.cellWidth === next.cellWidth &&
previous.cellHeight === next.cellHeight &&
previous.textStyle === next.textStyle
previous.textStyle === next.textStyle &&
previous.wideGlyphStyles === next.wideGlyphStyles
);
});
@@ -235,6 +282,8 @@ function TerminalGridRow({
cellHeight,
fontFamily,
fontSize,
letterSpacing,
wideGlyphStyles,
}: TerminalGridRowProps) {
const rowStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.row, { height: cellHeight }],
@@ -248,9 +297,10 @@ function TerminalGridRow({
lineHeight: cellHeight,
fontFamily,
fontSize,
letterSpacing,
},
],
[cellHeight, fontFamily, fontSize],
[cellHeight, fontFamily, fontSize, letterSpacing],
);
const accessibilityLabel = useMemo(
() =>
@@ -275,6 +325,7 @@ function TerminalGridRow({
cellWidth={cellWidth}
cellHeight={cellHeight}
textStyle={textStyle}
wideGlyphStyles={wideGlyphStyles}
/>
))}
</View>
@@ -288,10 +339,38 @@ const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => {
previous.cellHeight === next.cellHeight &&
previous.fontFamily === next.fontFamily &&
previous.fontSize === next.fontSize &&
previous.letterSpacing === next.letterSpacing &&
previous.wideGlyphStyles === next.wideGlyphStyles &&
previous.styleEpoch === next.styleEpoch
);
});
interface TerminalWideGlyphMeasureProps {
glyphClass: TerminalWideGlyphClass;
sample: string;
style: StyleProp<TextStyle>;
onMeasure: (glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => void;
}
// Measures one wide script's advance in the terminal font's fallback, like MEASURE_TEXT does for
// the terminal font itself.
function TerminalWideGlyphMeasure({
glyphClass,
sample,
style,
onMeasure,
}: TerminalWideGlyphMeasureProps) {
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onMeasure(glyphClass, event.nativeEvent.layout.width),
[glyphClass, onMeasure],
);
return (
<Text onLayout={handleLayout} pointerEvents="none" style={style}>
{sample.repeat(MEASURE_TEXT.length)}
</Text>
);
}
export function TerminalGridView({
state,
xtermTheme = DEFAULT_TERMINAL_THEME,
@@ -303,6 +382,10 @@ export function TerminalGridView({
}: TerminalGridViewProps) {
const [metrics, setMetrics] = useState<CellMetrics>(() => estimateCellMetrics(fontSize));
const measuredMetricsRef = useRef<TerminalGridCellMetrics | null>(null);
const [letterSpacing, setLetterSpacing] = useState(0);
const [wideGlyphWidths, setWideGlyphWidths] = useState<
Partial<Record<TerminalWideGlyphClass, number>>
>({});
const [viewport, setViewport] = useState<TerminalGridViewport | null>(null);
const resolvedFontFamily = useMemo(
() => resolveNativeTerminalFontFamily(fontFamily),
@@ -381,6 +464,14 @@ export function TerminalGridView({
measureTextLength: MEASURE_TEXT.length,
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
});
setLetterSpacing(
resolveTerminalTextLetterSpacing({
measuredTextWidth: event.nativeEvent.layout.width,
measureTextLength: MEASURE_TEXT.length,
cellWidth: nextMetrics.cellWidth,
cellsPerGlyph: 1,
}),
);
const changedMetrics = resolveTerminalGridMetricsMeasurement(
measuredMetricsRef.current,
nextMetrics,
@@ -395,6 +486,35 @@ export function TerminalGridView({
[onCellMetricsChange],
);
const handleWideGlyphMeasure = useCallback(
(glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => {
setWideGlyphWidths((current) =>
current[glyphClass] === measuredTextWidth
? current
: { ...current, [glyphClass]: measuredTextWidth },
);
},
[],
);
const wideGlyphStyles = useMemo(() => {
const entries = TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => {
const measuredTextWidth = wideGlyphWidths[spec.name];
const spacingStyle: TextStyle = {
letterSpacing:
measuredTextWidth === undefined
? 0
: resolveTerminalTextLetterSpacing({
measuredTextWidth,
measureTextLength: MEASURE_TEXT.length,
cellWidth: metrics.cellWidth,
cellsPerGlyph: 2,
}),
};
return [spec.name, spacingStyle] as const;
});
return Object.fromEntries(entries) as TerminalWideGlyphStyles;
}, [metrics.cellWidth, wideGlyphWidths]);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setViewport((current) => {
@@ -411,6 +531,15 @@ export function TerminalGridView({
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
{MEASURE_TEXT}
</Text>
{TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => (
<TerminalWideGlyphMeasure
key={spec.name}
glyphClass={spec.name}
sample={spec.sample}
style={measureStyle}
onMeasure={handleWideGlyphMeasure}
/>
))}
{rows.map((row) => (
<MemoTerminalGridRow
key={row.index}
@@ -419,6 +548,8 @@ export function TerminalGridView({
cellHeight={metrics.cellHeight}
fontFamily={resolvedFontFamily}
fontSize={fontSize}
letterSpacing={letterSpacing}
wideGlyphStyles={wideGlyphStyles}
styleEpoch={resolver.themeKey}
/>
))}
@@ -441,6 +572,17 @@ const styles = StyleSheet.create({
run: {
overflow: "hidden",
},
isolatedRun: {
overflow: "visible",
},
// The run box owns cell dimensions and background. Text gets extra layout room without
// changing those dimensions; only isolated fallback glyphs paint beyond the run box.
runText: {
backgroundColor: "transparent",
left: 0,
position: "absolute",
top: 0,
},
customGlyphRun: {
flex: 1,
position: "relative",
@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import React from "react";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import { TerminalGridView } from "./terminal-grid-view.native";
afterEach(cleanup);
describe("native terminal grid text layout", () => {
it("gives short runs between accented glyphs layout room without widening their cells", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 1, cols: 30 });
try {
await terminal.write("café naïve|END");
const view = render(<TerminalGridView state={terminal.getViewportState()} />);
const text = view.getByText(" na", { normalizer: (value) => value });
const run = text.parentElement!;
const accentRun = view.getByText("é").parentElement!;
const cellWidth = parseFloat(getComputedStyle(accentRun).width);
expect(parseFloat(getComputedStyle(run).width)).toBe(3 * cellWidth);
// Native Text wraps or ellipsizes when rounding or trailing letter spacing puts this
// leading-space run just past its layout width. Layout has room; the cell box clips paint.
expect(parseFloat(getComputedStyle(text).width)).toBeGreaterThan(3 * cellWidth);
expect(getComputedStyle(text).position).toBe("absolute");
expect(getComputedStyle(run).overflowX).toBe("hidden");
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe("café naïve|END");
} finally {
terminal.dispose();
}
});
});
@@ -37,7 +37,95 @@ describe("terminal row model", () => {
});
expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([
{ text: "A界", cellCount: 3 },
{ text: "A", cellCount: 2 },
{ text: "界", cellCount: 1 },
]);
});
test("keeps wide scripts in the run as segments the renderer spaces to two cells", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const rows = buildRows({
grid: [
[
cell("한", { width: 2 }),
cell(" ", { width: 0 }),
cell("글", { width: 2 }),
cell(" ", { width: 0 }),
cell(" ", { width: 1 }),
cell("中", { width: 2 }),
cell(" ", { width: 0 }),
cell("あ", { width: 2 }),
cell(" ", { width: 0 }),
cell("o", { width: 1 }),
cell("k", { width: 1 }),
],
],
resolver,
});
expect(
rows[0].runs.map((run) => ({
text: run.text,
cellCount: run.cellCount,
segments: run.renderKind === "text" ? run.segments : null,
})),
).toEqual([
{
text: "한글 中あok",
cellCount: 11,
segments: [
{ text: "한글", wideGlyphClass: "hangul" },
{ text: " ", wideGlyphClass: null },
{ text: "中", wideGlyphClass: "han" },
{ text: "あ", wideGlyphClass: "kana" },
{ text: "ok", wideGlyphClass: null },
],
},
]);
});
test("gives wide glyphs outside the spaced scripts their own box", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const [row] = buildRows({
grid: [
[
cell("ㄱ", { width: 2 }),
cell(" ", { width: 0 }),
cell("A", { width: 2 }),
cell(" ", { width: 0 }),
cell("가", { width: 2 }),
cell(" ", { width: 0 }),
],
],
resolver,
});
expect(
row.runs.map((run) => [run.text, run.cellCount, run.renderKind === "text" && run.isolated]),
).toEqual([
["ㄱ", 2, true],
["A", 2, true],
["가", 2, false],
]);
});
test("gives non-ASCII narrow glyphs their own cell", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const [row] = buildRows({
grid: [
["⏺", " ", "e\u0301", " ", "d", "o", "n", "e"].map((char) => cell(char, { width: 1 })),
],
resolver,
});
expect(row.runs.map((run) => [run.text, run.cellCount])).toEqual([
["⏺", 1],
[" ", 1],
["e\u0301", 1],
[" done", 5],
]);
});
@@ -27,8 +27,40 @@ interface TerminalRunBase {
foregroundColor: string;
}
export type TerminalWideGlyphClass = "hangul" | "han" | "kana";
interface TerminalWideGlyphClassSpec {
name: TerminalWideGlyphClass;
sample: string;
ranges: ReadonlyArray<readonly [number, number]>;
}
// Wide scripts whose glyphs share one advance within a font. The renderer measures each sample
// once and letter-spaces the script so every glyph advances exactly two cells.
export const TERMINAL_WIDE_GLYPH_CLASSES: ReadonlyArray<TerminalWideGlyphClassSpec> = [
{ name: "hangul", sample: "가", ranges: [[0xac00, 0xd7a3]] },
{
name: "han",
sample: "中",
ranges: [
[0x3400, 0x4dbf],
[0x4e00, 0x9fff],
],
},
{ name: "kana", sample: "あ", ranges: [[0x3041, 0x30ff]] },
];
export interface TerminalTextSegment {
text: string;
/** Null for terminal-font text, which advances one cell per glyph. */
wideGlyphClass: TerminalWideGlyphClass | null;
}
export interface TerminalTextRun extends TerminalRunBase {
renderKind: "text";
/** Holds one glyph whose fallback-font advance is unknown, drawn in its own box. */
isolated: boolean;
segments: TerminalTextSegment[];
}
export interface TerminalCustomGlyphRun extends TerminalRunBase {
@@ -78,6 +110,43 @@ function terminalCharWidth(char: string): number {
return isWide ? 2 : 1;
}
// Text runs render with the font's natural advances. Printable ASCII comes from the terminal font
// and the spaced wide scripts get a measured letter spacing, so both stay on the cell grid. Any
// other glyph (symbols, emoji, jamo) falls back to a font with an unknown advance; inside a run it
// would shift every glyph after it and pull the text away from the cursor.
function resolveGlyphSpacing(
char: string,
cellCount: number,
): TerminalWideGlyphClass | null | "isolated" {
if (char.length !== 1) {
return "isolated";
}
const code = char.charCodeAt(0);
if (cellCount === 1 && code >= 0x20 && code <= 0x7e) {
return null;
}
if (cellCount !== 2) {
return "isolated";
}
const glyphClass = TERMINAL_WIDE_GLYPH_CLASSES.find((spec) =>
spec.ranges.some(([start, end]) => code >= start && code <= end),
);
return glyphClass?.name ?? "isolated";
}
function appendSegment(
segments: TerminalTextSegment[],
text: string,
wideGlyphClass: TerminalWideGlyphClass | null,
): void {
const last = segments[segments.length - 1];
if (last && last.wideGlyphClass === wideGlyphClass) {
last.text += text;
return;
}
segments.push({ text, wideGlyphClass });
}
function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean {
if (terminalCharWidth(char) < 2) {
return false;
@@ -107,15 +176,23 @@ function appendRun(input: {
col: number;
}): void {
const renderKind = input.customGlyph ? "custom-glyph" : "text";
const spacing = input.customGlyph ? null : resolveGlyphSpacing(input.text, input.cellCount);
const isolated = spacing === "isolated";
const wideGlyphClass = spacing === "isolated" ? null : spacing;
const previousRun = input.runs[input.runs.length - 1];
if (
!isolated &&
previousRun &&
previousRun.styleKey === input.styleKey &&
previousRun.renderKind === renderKind
previousRun.renderKind === renderKind &&
!(previousRun.renderKind === "text" && previousRun.isolated)
) {
const offset = previousRun.cellCount;
previousRun.text += input.text;
previousRun.cellCount += input.cellCount;
if (previousRun.renderKind === "text") {
appendSegment(previousRun.segments, input.text, wideGlyphClass);
}
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
previousRun.glyphs.push({
key: `${input.col}:${input.text}`,
@@ -142,7 +219,12 @@ function appendRun(input: {
});
return;
}
input.runs.push({ ...baseRun, renderKind: "text" });
input.runs.push({
...baseRun,
renderKind: "text",
isolated,
segments: [{ text: input.text, wideGlyphClass }],
});
}
function cellIntersectsSelection(input: {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.13.2",
"version": "0.13.0",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.13.2",
"version": "0.13.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.13.2",
"version": "0.13.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.2",
"version": "0.13.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.13.2",
"version": "0.13.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.13.2",
"version": "0.13.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.13.2",
"version": "0.13.0",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.13.2",
"version": "0.13.0",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.13.2",
"version": "0.13.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1,4 +1,4 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
@@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { withClaudeReplayHistory } from "./transcript-history.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { ClaudeQueryFactory } from "./query.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
/**
* A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline.
@@ -29,19 +32,18 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(): Promise<void> {
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory: vi.fn(() => {
throw new Error("history load must not start a query");
}),
queryFactory:
queryFactory ??
(() => {
throw new Error("history load must not start a query");
}),
resolveBinary: async () => process.execPath,
resolveVersion: async () => "2.1.220",
});
const session = await client.resumeSession(
{ provider: "claude", sessionId: SESSION_ID },
{ cwd },
);
await session.close();
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
}
function transcriptPath(): string {
@@ -58,12 +60,218 @@ describe("ClaudeAgentSession persisted history load", () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
rmSync(tempRoot, { recursive: true, force: true });
});
test.each(["file_path", "files"])(
"replays %s without probing targets and preserves live existence checks",
async (field) => {
const existingPath = path.join(cwd, "existing.txt");
const missingPath = path.join(cwd, "missing.txt");
writeFileSync(existingPath, "existing");
const targets = [existingPath, missingPath];
const inputs = targets.map((target) => ({
[field]: field === "files" ? [target] : target,
}));
const toolBlocks = inputs.map((input, index) => ({
type: "tool_use",
id: `change-${index}`,
name: "FileChange",
input,
}));
const resultBlocks = toolBlocks.map((block) => ({
type: "tool_result",
tool_use_id: block.id,
content: "done",
}));
const transcript = [
{ type: "user", message: { content: "Change files" } },
{ type: "assistant", message: { content: toolBlocks } },
{ type: "user", message: { content: resultBlocks } },
{ type: "assistant", message: { content: "Finished" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), transcript.map((entry) => JSON.stringify(entry)).join("\n"));
// Observe the real filesystem calls; neither existing nor missing targets are mocked.
const exists = vi.spyOn(fs, "existsSync");
const liveMessages = [
{ type: "assistant", message: { content: toolBlocks } },
{ type: "user", message: { content: resultBlocks } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
const queryFactory = vi.fn();
queryFactory.mockReturnValue({
async next() {
const value = liveMessages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
});
const session = await resume(queryFactory);
try {
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual(
[],
);
const running = inputs.map((input, index) => ({
type: "tool_call",
callId: `change-${index}`,
name: "FileChange",
status: "running",
error: null,
detail: { type: "unknown", input, output: null },
}));
const completed = inputs.map((input, index) => ({
type: "tool_call",
callId: `change-${index}`,
name: "FileChange",
status: "completed",
error: null,
detail: {
type: "unknown",
input,
output: {
output: "done",
files: [{ path: index === 0 ? "existing.txt" : "missing.txt", kind: "update" }],
},
},
}));
expect(history).toEqual(
[
{ type: "user_message", text: "Change files" },
...running,
...completed,
{ type: "assistant_message", text: "Finished" },
].map((item) => ({ type: "timeline", provider: "claude", timestamp: undefined, item })),
);
exists.mockClear();
const live = await collectSessionTurnEvents(session, "Change files again");
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual([
[existingPath],
[missingPath],
]);
const liveCompleted = live.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed"
? [event.item]
: [],
);
const missing = completed[1];
expect(liveCompleted).toEqual([
completed[0],
{
...missing,
detail: {
...missing.detail,
output: { output: "done", files: [{ path: "missing.txt", kind: "add" }] },
},
},
]);
} finally {
await session.close();
}
},
);
test.each(["parent", "subagent"])(
"replays %s Write and Edit calls without probing their targets",
async (scope) => {
const filePath = path.join(cwd, "unavailable", "file.txt");
const content = ["Write", "Edit"].map((name) => ({
type: "tool_use",
id: name,
name,
input: { file_path: filePath },
}));
const entry = { type: "assistant", message: { content } };
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
if (scope === "subagent") {
const directory = path.join(path.dirname(transcriptPath()), SESSION_ID, "subagents");
mkdirSync(directory, { recursive: true });
writeFileSync(
path.join(directory, "agent-child.meta.json"),
JSON.stringify({ toolUseId: "task" }),
);
writeFileSync(
path.join(directory, "agent-child.jsonl"),
JSON.stringify({
...entry,
isSidechain: true,
agentId: "child",
}),
);
writeFileSync(
transcriptPath(),
JSON.stringify({
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "task",
name: "Task",
input: { description: "Change files" },
},
],
},
}),
);
} else {
writeFileSync(transcriptPath(), JSON.stringify(entry));
}
const exists = vi.spyOn(fs, "existsSync");
const session = await resume();
try {
const items = [];
for await (const event of session.streamHistory()) {
if (scope === "parent" && event.type === "timeline") items.push(event.item);
if (event.type === "provider_subagent" && event.event.type === "timeline") {
items.push(event.event.item);
}
}
expect(exists.mock.calls.filter(([target]) => target === filePath)).toEqual([]);
expect(items).toEqual([
{
type: "tool_call",
callId: "Write",
name: "Write",
status: "running",
error: null,
detail: { type: "write", filePath },
},
{
type: "tool_call",
callId: "Edit",
name: "Edit",
status: "running",
error: null,
detail: { type: "edit", filePath },
},
]);
} finally {
await session.close();
}
},
);
test("logs the resolved transcript path when the transcript does not exist", async () => {
await resume();
await (await resume()).close();
expect(records).toContainEqual(
expect.objectContaining({
@@ -79,7 +287,7 @@ describe("ClaudeAgentSession persisted history load", () => {
// A directory at the transcript path exists but cannot be read as a file.
mkdirSync(transcriptPath(), { recursive: true });
await resume();
await (await resume()).close();
expect(records).toContainEqual(
expect.objectContaining({
@@ -148,7 +356,7 @@ describe("ClaudeAgentSession persisted history load", () => {
chmodSync(path.join(configDir, "projects"), 0o000);
try {
await resume();
await (await resume()).close();
} finally {
chmodSync(path.join(configDir, "projects"), 0o755);
}
@@ -2326,6 +2326,7 @@ class ClaudeAgentSession implements AgentSession {
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
private cancelCurrentTurn: (() => void) | null = null;
@@ -5253,6 +5254,7 @@ class ClaudeAgentSession implements AgentSession {
private loadPersistedHistory(sessionId: string): void {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = this.resolveHistoryPath(sessionId);
@@ -5272,6 +5274,8 @@ class ClaudeAgentSession implements AgentSession {
{ err: error, sessionId, historyPath },
"Failed to load Claude history from transcript",
);
} finally {
this.replayingHistory = false;
}
}
@@ -6039,6 +6043,11 @@ class ClaudeAgentSession implements AgentSession {
}
private detectFileKind(filePath: string): string {
// A historical target may be on an unreachable mount; its current state says nothing
// about whether the past operation created or updated it.
if (this.replayingHistory) {
return "update";
}
try {
return fs.existsSync(filePath) ? "update" : "add";
} catch {
@@ -0,0 +1,80 @@
import type { SessionInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import { SessionChildren } from "./children.js";
function setup() {
const harness = new V2Harness();
const child: SessionInfo = {
...harness.info,
id: "child",
parentID: "session",
title: "Check the worker model",
agent: "worker",
model: { providerID: "test", id: "worker-model", variant: "xhigh" },
tokens: { input: 10000, output: 3000, reasoning: 2000, cache: { read: 3000, write: 800 } },
};
harness.api.session.list = async (input) => ({
data: input?.parentID === "session" ? [child] : [],
cursor: {},
});
harness.api.session.get = async () => child;
const events: AgentStreamEvent[] = [];
const children = new SessionChildren({
client: () => harness.api,
id: "session",
emit: (event) => events.push(event),
reconcilePermissions: async () => {},
});
const upserts = () =>
events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
return { children, child, upserts };
}
describe("OpenCode v2 child presentation", () => {
test("restores agent, model, variant and all token categories in the child subtitle", async () => {
const { children, upserts } = setup();
await children.reconcile("session");
expect(upserts()).toEqual([
expect.objectContaining({
id: "child",
title: "Check the worker model",
parentSubagentId: null,
status: "completed",
subtitle: "worker · worker-model · Extra High · 18.8k tokens",
}),
]);
await children.reconcile("session");
expect(upserts()).toHaveLength(1);
});
test("publishes changed child facts on live events even when its timestamp is unchanged", async () => {
const { children, child, upserts } = setup();
await children.observe({ id: "created", created: 1, type: "session.created", data: child });
child.model = { providerID: "test", id: "next-model", variant: "low" };
child.tokens.output += 1200;
await children.observe({
id: "finished",
created: 2,
type: "session.execution.succeeded",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child" },
});
expect(upserts().map((event) => event.subtitle)).toEqual([
"worker · worker-model · Extra High · 18.8k tokens",
"worker · next-model · Low · 20k tokens",
]);
});
test("keeps available agent information before a child has a model or token usage", async () => {
const { children, child, upserts } = setup();
delete child.model;
child.tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
await children.reconcile("session");
expect(upserts()[0]).toMatchObject({ title: "Check the worker model", subtitle: "worker" });
});
});
@@ -7,6 +7,7 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
import { messages } from "./history.js";
import { buildOpenCodeSubagentSubtitle } from "../subagent-presentation.js";
import type { V2Api } from "./api.js";
interface ChildrenOptions {
@@ -65,11 +66,23 @@ export class SessionChildren {
if (active[info.id]) status = "running";
else if (info.outcome === "failed") status = "failed";
else if (info.outcome === "interrupted") status = "canceled";
const subtitle = buildOpenCodeSubagentSubtitle({
agentName: info.agent,
modelId: info.model?.id,
variant: info.model?.variant,
totalTokens:
info.tokens.input +
info.tokens.output +
info.tokens.reasoning +
info.tokens.cache.read +
info.tokens.cache.write,
});
const presentation: ProviderSubagentInputEvent = {
type: "upsert",
id: info.id,
parentSubagentId: info.parentID === this.options.id ? null : info.parentID,
title: info.title ?? null,
...(subtitle ? { subtitle } : {}),
status,
cwd: info.location.directory,
timestamp: new Date(info.time.updated).toISOString(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.13.2",
"version": "0.13.0",
"private": true,
"type": "module",
"scripts": {
-62
View File
@@ -13,27 +13,6 @@ index ee51e76..dc1bc17 100644
if (_providedOnChange) {
_providedOnChange(_index, _position, _type);
}
diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js
index 973d05c..b484b5f 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js
@@ -86,7 +86,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s
});
}, [state, verticalInset, modal]);
(0, _react.useEffect)(() => {
- _reactNative.Dimensions.addEventListener('change', ({
+ const subscription = _reactNative.Dimensions.addEventListener('change', ({
window
}) => {
state.modify(_state => {
@@ -96,6 +96,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s
return _state;
});
});
+ return () => subscription.remove();
}, [state]);
//#endregion
diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js
index e44ad86..96555ef 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js
@@ -75,27 +54,6 @@ index 94fabdb..60ca66a 100644
if (_providedOnChange) {
_providedOnChange(_index, _position, _type);
}
diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js
index b0eec93..67713a2 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js
@@ -82,7 +82,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m
});
}, [state, verticalInset, modal]);
useEffect(() => {
- Dimensions.addEventListener('change', ({
+ const subscription = Dimensions.addEventListener('change', ({
window
}) => {
state.modify(_state => {
@@ -92,6 +92,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m
return _state;
});
});
+ return () => subscription.remove();
}, [state]);
//#endregion
diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/index.js b/node_modules/@gorhom/bottom-sheet/lib/module/index.js
index 8259637..eb057fe 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/module/index.js
@@ -143,26 +101,6 @@ index 2954b3f..7260656 100644
if (_providedOnChange) {
_providedOnChange(_index, _position, _type);
diff --git a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts
index 68c674d..564833c 100644
--- a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts
+++ b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts
@@ -106,13 +106,14 @@ export function useAnimatedLayout(
[state, verticalInset, modal]
);
useEffect(() => {
- Dimensions.addEventListener('change', ({ window }) => {
+ const subscription = Dimensions.addEventListener('change', ({ window }) => {
state.modify(_state => {
'worklet';
_state.window = window;
return _state;
});
});
+ return () => subscription.remove();
}, [state]);
//#endregion
diff --git a/node_modules/@gorhom/bottom-sheet/src/index.ts b/node_modules/@gorhom/bottom-sheet/src/index.ts
index 7aaea57..6a5f5a4 100644
--- a/node_modules/@gorhom/bottom-sheet/src/index.ts
@@ -40,10 +40,9 @@ index 0a1c6c2..5935112 100644
struct OnLinkLongPress {
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt
index c12937c..8105833 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt
+++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt
@@ -95,7 +95,7 @@ class EnrichedMarkdown(
@@ -95,7 +95,7 @@
private var accessibilityLabels = AccessibilityLabels()
private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY
@@ -52,7 +51,7 @@ index c12937c..8105833 100644
private var onLinkLongPressCallback: ((String) -> Unit)? = null
private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null
private var onLatexErrorCallback: LatexErrorReporter? = null
@@ -296,7 +296,7 @@ class EnrichedMarkdown(
@@ -296,7 +296,7 @@
}
}
@@ -61,7 +60,7 @@ index c12937c..8105833 100644
onLinkPressCallback = callback
}
@@ -405,7 +405,7 @@ class EnrichedMarkdown(
@@ -405,7 +405,7 @@
val codeLinkRegex = inlineCodeLinkRegex
val renderId = ++currentRenderId
@@ -70,7 +69,7 @@ index c12937c..8105833 100644
try {
val filtered =
if (isStreaming) {
@@ -418,13 +418,13 @@ class EnrichedMarkdown(
@@ -418,13 +418,13 @@
if (renderableMarkdown.isEmpty()) {
postToMain(renderId) { applyRenderedSegments(emptyList(), false) }
@@ -86,19 +85,7 @@ index c12937c..8105833 100644
}
val segments = splitASTIntoSegments(ast)
@@ -478,6 +478,11 @@ class EnrichedMarkdown(
if (forceHeight || topologyChanged || heightBefore != heightAfter) {
MeasurementStore.invalidate(id)
requestLayout()
+ stateWrapper?.let {
+ val state = Arguments.createMap()
+ state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter)
+ it.updateState(state)
+ }
}
}
}
@@ -646,8 +651,12 @@ class EnrichedMarkdown(
@@ -646,8 +646,12 @@
renderId: Long,
action: () -> Unit,
) {
@@ -3151,89 +3138,3 @@ new file mode 100644
+ }
+ }
+}
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp
index a430d1a..b30a017 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp
+++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp
@@ -22,6 +22,14 @@ void MarkdownContainerShadowNode::dirtyLayoutIfNeeded() {
}
}
+// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild
+// re-clones its children without new props, and dirtying those clones re-measures every settled message
+// whenever the list above grows.
+bool MarkdownContainerShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/,
+ const ShadowNodeFragment &fragment) const {
+ return fragment.props != nullptr;
+}
+
Size MarkdownContainerShadowNode::measureContent(const LayoutContext &layoutContext,
const LayoutConstraints &layoutConstraints) const {
return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints);
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h
index dea8bb2..6c4bd20 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h
+++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h
@@ -19,6 +19,8 @@ public:
MarkdownContainerShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment)
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
+ forceHeightRecalculationCounter_ =
+ static_cast<const MarkdownContainerShadowNode &>(sourceShadowNode).forceHeightRecalculationCounter_;
dirtyLayoutIfNeeded();
}
@@ -35,6 +37,10 @@ public:
Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override;
+protected:
+ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode,
+ const ShadowNodeFragment &fragment) const override;
+
private:
int forceHeightRecalculationCounter_{0};
std::shared_ptr<MarkdownContainerMeasurementManager> measurementsManager_;
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp
index 3ca259b..ab51055 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp
+++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp
@@ -22,6 +22,14 @@ void MarkdownTextShadowNode::dirtyLayoutIfNeeded() {
}
}
+// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild
+// re-clones its children without new props, and dirtying those clones re-measures every settled message
+// whenever the list above grows.
+bool MarkdownTextShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/,
+ const ShadowNodeFragment &fragment) const {
+ return fragment.props != nullptr;
+}
+
Size MarkdownTextShadowNode::measureContent(const LayoutContext &layoutContext,
const LayoutConstraints &layoutConstraints) const {
return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints);
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h
index eaa3279..05a02eb 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h
+++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h
@@ -22,6 +22,8 @@ public:
MarkdownTextShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment)
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
+ forceHeightRecalculationCounter_ =
+ static_cast<const MarkdownTextShadowNode &>(sourceShadowNode).forceHeightRecalculationCounter_;
dirtyLayoutIfNeeded();
}
@@ -39,6 +41,10 @@ public:
Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override;
+protected:
+ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode,
+ const ShadowNodeFragment &fragment) const override;
+
private:
int forceHeightRecalculationCounter_{0};
std::shared_ptr<MarkdownTextMeasurementManager> measurementsManager_;
@@ -1,35 +0,0 @@
diff --git a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt
index 48e3771..0fba1af 100644
--- a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt
+++ b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt
@@ -245,6 +245,15 @@ open class NativeProxy {
}.apply { isAccessible = true }
}
+ // Backport of https://github.com/software-mansion/react-native-reanimated/pull/10435
+ // (shipped in Reanimated 4.7.1). The reflective updatePropsSynchronously path skips RN's
+ // view-existence guard, so a tag that unmounted with buffered updates throws
+ // RetryableMountingLayerException on every event dispatch and Log.w printed the full stack
+ // on the UI thread inside the draw pass. Remove once the installed Reanimated includes the fix.
+ private val getViewExistsMethod by lazy {
+ runCatching { mountingManager.javaClass.getMethod("getViewExists", Int::class.javaPrimitiveType) }.getOrNull()
+ }
+
@DoNotStrip
fun synchronouslyUpdateUIProps(
intBuffer: IntArray,
@@ -253,9 +262,13 @@ open class NativeProxy {
SynchronousPropsBufferParser.parse(intBuffer, doubleBuffer) { viewTag, props ->
if (BuildConfig.IS_REACT_NATIVE_86_OR_NEWER) {
try {
+ // The props stay in the registry, and the commit hook applies them to the shadow tree.
+ if (getViewExistsMethod?.invoke(mountingManager, viewTag) == false) {
+ return@parse
+ }
updatePropsSynchronouslyMethod.invoke(mountingManager, viewTag, props)
} catch (e: Exception) {
- Log.w("Reanimated", "synchronouslyUpdateUIProps failed for tag $viewTag", e)
+ Log.w("Reanimated", "synchronouslyUpdateUIProps failed for tag $viewTag: ${e.cause ?: e}")
}
} else {
mFabricUIManager.synchronouslyUpdateViewOnUIThread(viewTag, props)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.13.2",
"version": "0.13.0",
"private": true,
"type": "module",
"scripts": {
-14
View File
@@ -14,9 +14,6 @@ const patchedPackages = [
nodeModulesPath: "node_modules/expo-modules-core",
patchPrefix: "expo-modules-core+",
},
// Backport https://github.com/software-mansion/enriched-markdown/pull/924: preserve settled
// Android measurements across parent clones. Remove those hunks when upgrading past
// 1.2.0-nightly-20261019-926ef06f0; reconcile the other local patch hunks separately.
{
nodeModulesPath: "packages/app/node_modules/react-native-enriched-markdown",
patchPrefix: "react-native-enriched-markdown+",
@@ -28,14 +25,6 @@ const patchedPackages = [
nodeModulesPath: "node_modules/react-native-unistyles",
patchPrefix: "react-native-unistyles+",
},
// Backport of https://github.com/software-mansion/react-native-reanimated/pull/10435
// (Reanimated 4.7.1): skip synchronous Android prop updates for unmounted view tags
// instead of throwing and printing a full stack trace on the UI thread per tag per event.
// Remove when the installed Reanimated is >= 4.7.1.
{
nodeModulesPath: "node_modules/react-native-reanimated",
patchPrefix: "react-native-reanimated+",
},
{
nodeModulesPath: "node_modules/react-native-draggable-flatlist",
patchPrefix: "react-native-draggable-flatlist+",
@@ -48,9 +37,6 @@ const patchedPackages = [
// modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and
// keeps a dismissing modal's status through its final onChange(-1), so a modal with
// enableDismissOnClose=false that unmounts while presented still leaves the portal.
// Dimensions subscription cleanup: https://github.com/gorhom/react-native-bottom-sheet/issues/2734
// and https://github.com/gorhom/react-native-bottom-sheet/pull/2751. Remove the useAnimatedLayout
// hunks once an installed release includes the cleanup; retain the other local hunks.
{
nodeModulesPath: "node_modules/@gorhom/bottom-sheet",
patchPrefix: "@gorhom+bottom-sheet+",