mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e635d9b46 | ||
|
|
3e859625ed | ||
|
|
83b1a3cbe2 | ||
|
|
0132f9f3e3 |
@@ -1,3 +0,0 @@
|
||||
vendor/react-native-gpui/** linguist-vendored
|
||||
packages/app/gpui/package-lock.json linguist-generated
|
||||
packages/app/gpui/desktop/Cargo.lock linguist-generated
|
||||
@@ -98,9 +98,3 @@ app.json
|
||||
|
||||
# Project-local Serena configuration and memories.
|
||||
.serena/
|
||||
|
||||
# Vendored experimental React Native desktop runtime.
|
||||
!vendor/
|
||||
!vendor/react-native-gpui/
|
||||
!vendor/react-native-gpui/**
|
||||
!packages/app/gpui/app.json
|
||||
|
||||
+1
-1
@@ -11,5 +11,5 @@
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx", "vendor/**"]
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"ignorePatterns": [".dev/**", "vendor/**"],
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
|
||||
"categories": {
|
||||
|
||||
@@ -61,18 +61,6 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
|
||||
|
||||
## Local build + install
|
||||
|
||||
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
|
||||
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
|
||||
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
|
||||
regression renders real gradient pixels across growing and shrinking layouts, including a nested
|
||||
group. From `packages/app/android`, run it on your selected emulator:
|
||||
|
||||
```bash
|
||||
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
|
||||
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
||||
It backports
|
||||
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
||||
|
||||
@@ -65,7 +65,6 @@ per-agent examples and provider-specific keys.
|
||||
## Table of Contents
|
||||
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||
@@ -108,35 +107,6 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
|
||||
|
||||
---
|
||||
|
||||
## OpenCode with an existing v2 server
|
||||
|
||||
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"opencode": {
|
||||
"env": {
|
||||
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
|
||||
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
|
||||
|
||||
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
|
||||
|
||||
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
|
||||
|
||||
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||
|
||||
@@ -29,13 +29,6 @@ the titlebar row. Production builds leave the variable unset and show no label.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
|
||||
## Experimental GPUI client
|
||||
|
||||
The opt-in [GPUI target](../packages/app/gpui/README.md) runs the existing React
|
||||
Native app on Linux. Its contributor guide covers setup, the renderer comparison,
|
||||
unsupported features, native checks, and experiments to pick up. It connects to
|
||||
an already-running daemon and does not replace the Electron dev command.
|
||||
|
||||
## Nix desktop package
|
||||
|
||||
The flake exposes `packages.<system>.desktop` on Linux and macOS:
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||
|
||||
|
||||
+1
-5
@@ -122,11 +122,7 @@
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push",
|
||||
"gpui:install": "node scripts/gpui.mjs install",
|
||||
"gpui:bundle": "node scripts/gpui.mjs bundle",
|
||||
"build:gpui": "node scripts/gpui.mjs build",
|
||||
"dev:gpui": "node scripts/gpui.mjs run"
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -68,54 +67,3 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
||||
await page.getByRole("link", { name: linkName, exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
async function returnToNestedLinks(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "nested-file-link-",
|
||||
title: "Nested file links",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse:
|
||||
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "repo-a/schema/x.md"),
|
||||
"# Nested file content\n\nDetails\n",
|
||||
);
|
||||
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
||||
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
||||
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
||||
await readLinkedGuide(page, "nested guide", "Nested file content");
|
||||
await returnToNestedLinks(page);
|
||||
await readLinkedGuide(page, "exact guide", "Exact file content");
|
||||
await returnToNestedLinks(page);
|
||||
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
attachFileFromMenu,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
} from "../support/helpers/composer";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
@@ -369,56 +365,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
|
||||
const requests = recordCommandRequestsByAgentId(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "suggested-command-",
|
||||
title: "Suggested commands",
|
||||
initialPrompt: "Suggest `/mock` without running it",
|
||||
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
|
||||
await expect(message).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
await attachFileFromMenu(page, {
|
||||
name: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("attachment"),
|
||||
});
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await message.getByRole("link", { name: "Run mock" }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await composerLocator(page).fill("draft");
|
||||
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
|
||||
element.setSelectionRange(0, 0),
|
||||
);
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await composerLocator(page).pressSequentially("argument");
|
||||
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
|
||||
await composerLocator(page).fill("/");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toContainText("/mock");
|
||||
expect(requests).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
discardDictation,
|
||||
expectEmptyDictation,
|
||||
installSyntheticMicrophone,
|
||||
observeDictation,
|
||||
recordDictation,
|
||||
retryDictation,
|
||||
} from "../support/helpers/dictation";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// Silence is finalized by the real daemon without calling a transcription service.
|
||||
// A closed local endpoint makes an unexpected transcription request fail the test.
|
||||
test.use({
|
||||
e2eDaemonEnvironment: {
|
||||
PASEO_DICTATION_ENABLED: "1",
|
||||
PASEO_DICTATION_SILENCE_PEAK_THRESHOLD: "300",
|
||||
OPENAI_API_KEY: "unused-for-silent-recordings",
|
||||
OPENAI_BASE_URL: "http://127.0.0.1:9/v1",
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Dictation empty result", () => {
|
||||
test("the daemon accepts the same recording through repeated empty-result retries", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "dictation-empty-",
|
||||
title: "Empty dictation",
|
||||
});
|
||||
await installSyntheticMicrophone(page, { silent: true });
|
||||
const observed = observeDictation(page);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await recordDictation(page, observed);
|
||||
await expectEmptyDictation(page);
|
||||
await retryDictation(page);
|
||||
await expectEmptyDictation(page);
|
||||
await retryDictation(page);
|
||||
await expectEmptyDictation(page);
|
||||
|
||||
expect(observed.recordings).toHaveLength(3);
|
||||
expect(observed.recordings[0].audio.length).toBeGreaterThan(0);
|
||||
expect(observed.recordings[0].finalSeq).toBe(observed.recordings[0].audio.length - 1);
|
||||
expect(observed.recordings[0].text).toBe("");
|
||||
expect(observed.recordings[1]).toEqual(observed.recordings[0]);
|
||||
expect(observed.recordings[2]).toEqual(observed.recordings[0]);
|
||||
await discardDictation(page);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("narrow composer", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test("explains and discards an empty result", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "dictation-silent-",
|
||||
title: "Silent dictation",
|
||||
});
|
||||
await installSyntheticMicrophone(page, { silent: true });
|
||||
const observed = observeDictation(page);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await recordDictation(page, observed);
|
||||
await expectEmptyDictation(page);
|
||||
await discardDictation(page);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,8 +81,6 @@ for (const folder of [
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
"examples/next.js/",
|
||||
"examples/next.js",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -94,9 +92,6 @@ for (const folder of [
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
||||
await mkdir(path.dirname(competingFile), { recursive: true });
|
||||
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, type Page } from "../fixtures";
|
||||
|
||||
/**
|
||||
* Replaces the browser microphone with an oscillator. `silent` keeps the stream
|
||||
* running at zero gain, which is what a muted or wrongly routed input delivers.
|
||||
*/
|
||||
export async function installSyntheticMicrophone(
|
||||
page: Page,
|
||||
options: { silent?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await page.addInitScript((silent: boolean) => {
|
||||
const mediaDevices = navigator.mediaDevices;
|
||||
Object.defineProperty(mediaDevices, "getUserMedia", {
|
||||
configurable: true,
|
||||
value: async () => {
|
||||
const context = new AudioContext();
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = silent ? 0 : 1;
|
||||
const destination = context.createMediaStreamDestination();
|
||||
oscillator.connect(gain);
|
||||
gain.connect(destination);
|
||||
oscillator.start();
|
||||
return destination.stream;
|
||||
},
|
||||
});
|
||||
}, options.silent ?? false);
|
||||
}
|
||||
|
||||
/** Observe real browser/daemon traffic without intercepting or answering it. */
|
||||
export function observeDictation(page: Page) {
|
||||
const streams = new Map<string, { audio: Map<number, string>; finalSeq: number }>();
|
||||
const recordings: Array<{ audio: string[]; finalSeq: number; text: string }> = [];
|
||||
let resolveAudio!: () => void;
|
||||
const audio = new Promise<void>((resolve) => {
|
||||
resolveAudio = resolve;
|
||||
});
|
||||
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = sessionMessage(payload);
|
||||
if (!request || typeof request.dictationId !== "string") return;
|
||||
const id = request.dictationId;
|
||||
if (request.type === "dictation_stream_start") {
|
||||
streams.set(id, { audio: new Map(), finalSeq: -1 });
|
||||
}
|
||||
if (request.type === "dictation_stream_chunk") {
|
||||
streams.get(id)!.audio.set(request.seq as number, request.audio as string);
|
||||
resolveAudio();
|
||||
}
|
||||
if (request.type === "dictation_stream_finish") {
|
||||
streams.get(id)!.finalSeq = request.finalSeq as number;
|
||||
}
|
||||
});
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const response = sessionMessage(payload);
|
||||
if (response?.type !== "dictation_stream_final") return;
|
||||
const final = response.payload as { dictationId: string; text: string };
|
||||
const stream = streams.get(final.dictationId)!;
|
||||
recordings.push({
|
||||
audio: [...stream.audio.entries()].sort(([a], [b]) => a - b).map(([, chunk]) => chunk),
|
||||
finalSeq: stream.finalSeq,
|
||||
text: final.text,
|
||||
});
|
||||
});
|
||||
});
|
||||
return { waitForAudio: () => audio, recordings };
|
||||
}
|
||||
|
||||
function sessionMessage(payload: string | Buffer): Record<string, unknown> | null {
|
||||
try {
|
||||
const envelope = JSON.parse(typeof payload === "string" ? payload : payload.toString("utf8"));
|
||||
return envelope.type === "session" ? envelope.message : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordDictation(page: Page, observed: { waitForAudio(): Promise<void> }) {
|
||||
await page.getByRole("button", { name: "Start dictation", exact: true }).click();
|
||||
await observed.waitForAudio();
|
||||
await page.getByRole("button", { name: "Insert transcription", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectEmptyDictation(page: Page) {
|
||||
const message = page.getByText("No transcription was returned. Tap retry to try again.", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(message).toBeVisible();
|
||||
const bounds = await message.boundingBox();
|
||||
const cancel = await page
|
||||
.getByRole("button", { name: "Cancel dictation", exact: true })
|
||||
.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
expect(cancel).not.toBeNull();
|
||||
expect(bounds!.y).toBeGreaterThanOrEqual(cancel!.y);
|
||||
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(cancel!.y + cancel!.height);
|
||||
}
|
||||
|
||||
export async function retryDictation(page: Page) {
|
||||
await page.getByRole("button", { name: "Retry dictation", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function discardDictation(page: Page) {
|
||||
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
|
||||
}
|
||||
@@ -182,8 +182,8 @@ export async function startIsolatedHostDaemon(
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
env: {
|
||||
...withDisabledE2ESpeechEnv(process.env),
|
||||
...options.environment,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
@@ -192,7 +192,7 @@ export async function startIsolatedHostDaemon(
|
||||
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
desktop/build/
|
||||
desktop/autolink/
|
||||
@@ -1,3 +0,0 @@
|
||||
workspaces=false
|
||||
legacy-peer-deps=true
|
||||
install-links=true
|
||||
@@ -1,202 +0,0 @@
|
||||
# Experimental GPUI client
|
||||
|
||||
Run Paseo's existing React Native app in a native GPUI window on Linux. This is
|
||||
a community starting point: the real workspace UI runs, but terminal/diff
|
||||
renderers and several native services still need porting. The normal desktop
|
||||
build remains Electron. No daemon changes or release installers are included.
|
||||
|
||||
## Run it
|
||||
|
||||
Start from the repository's [development setup](../../../docs/development.md).
|
||||
A fresh checkout needs the root dependencies and workspace build outputs:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:server
|
||||
npm run gpui:install
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run dev:gpui
|
||||
```
|
||||
|
||||
Use an already-running compatible daemon and replace `localhost:6768` with its
|
||||
endpoint. These GPUI commands never start or restart a daemon. The address is
|
||||
inlined at bundle time. The app's normal connection/authentication rules apply.
|
||||
|
||||
You also need Rust stable through rustup, CMake, Ninja, Clang, Linux X11/Wayland
|
||||
development libraries, and Vulkan. The tested Linux x86_64 host already had
|
||||
most build tools; it additionally needed `libxkbcommon-x11-dev`. A clean-machine
|
||||
prerequisite check is still needed. The first native build downloads dependencies
|
||||
and builds Rust/C++; allow several minutes and several GB of disk.
|
||||
|
||||
`dev:gpui` bundles the app, autolinks native modules, builds, and opens the window.
|
||||
Restart after editing. It uses an offline production JS bundle; Fast Refresh is
|
||||
not wired into this launcher. `build:gpui` builds without opening a window.
|
||||
`gpui:bundle -- --dev false` only writes the JS bundle, source map, and assets to
|
||||
`.artifacts/gpui/`. Bundle validation checks that the real routes are present and
|
||||
that no `react-native-webview` modules were included.
|
||||
|
||||
## Approach and renderer choice
|
||||
|
||||
```text
|
||||
Existing Paseo RootApp + Expo Router + state/client/plugin code
|
||||
|
|
||||
React in Hermes
|
||||
|
|
||||
React Native Fabric
|
||||
|
|
||||
Rust GPUI native renderer
|
||||
|
|
||||
Linux GPU window
|
||||
|
||||
Existing Paseo client <-- WebSocket --> separately running Node daemon
|
||||
```
|
||||
|
||||
The separate npm project is deliberate: the selected runtime uses RN 0.87.1,
|
||||
while Paseo's mobile app uses RN 0.86.3. Metro redirects native dependencies to
|
||||
that installation and resolves the rest from the app. This avoids changing the
|
||||
mobile dependency baseline just to try another renderer. It also creates a
|
||||
version-maintenance cost that needs testing before this becomes a supported target.
|
||||
|
||||
The choice was to preserve the existing app's RN component and library contracts:
|
||||
|
||||
| Candidate | Integration model | Consequence for this experiment |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Streetwriters react-native-gpui](https://github.com/streetwriters/react-native-gpui/tree/de11aa265e6fb36c80e8a30315bce6d34a9be7d6) | React Native Fabric, Hermes, and GPUI, with ports of screens, gestures, Reanimated/worklets, safe areas, and SVG | Chosen because it lets the existing RN app reach a real Linux window with targeted adapters. These ports are still experimental. |
|
||||
| [natew/react-native-gpui](https://github.com/natew/react-native-gpui/tree/b2254543357f02fdd983d0d8a584d1d1bd93f5da) | Custom React reconciler with an RN-like component surface; its README targets macOS arm64/Metal | Useful implementation reference. Running this Linux app would also require platform work and compatibility with the RN libraries it imports. |
|
||||
| [GPUIX](https://github.com/remorses/gpuix) | Custom React/Solid renderer, its own JSX primitives, and Node-API native bindings; supports Bun/Node execution | Also native GPUI, with interesting packaging and specialized surfaces. Adopting it would require replacing or adapting Paseo's RN component/library contracts. It remains an alternative worth testing, not a rejected renderer. |
|
||||
| Existing Electron target | RN Web inside Chromium, with the desktop host bridge | Preserves the working desktop feature set, but does not answer the experiment's question of running the app through a native renderer. |
|
||||
|
||||
This is an integration choice, not a performance comparison. There are no
|
||||
controlled CPU, memory, startup, or frame-time results against these alternatives.
|
||||
|
||||
## What belongs in the change
|
||||
|
||||
| Area | Why it is here |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `vendor/react-native-gpui/` | Editable, pinned runtime source. This accounts for roughly 130,000 lines of the diff. GitHub marks it as vendored; inspect [UPSTREAM](../../../vendor/react-native-gpui/UPSTREAM) for provenance and the two local patches. |
|
||||
| npm and Cargo lockfiles | Reproduce the experiment's dependencies. Roughly 15,000 generated lines, collapsed on GitHub. |
|
||||
| `desktop/`, `index.js`, `routes.js`, Metro/Babel config, root launcher | Register the full app, select the native runtime, compile native services, and bundle the actual route graph. The explicit route context prevents an apparently successful build with no app routes. |
|
||||
| `adapters/` | Desktop implementations of the app-used APIs, plus explicit unavailable-capability implementations for mobile features. About 700 lines; these are limited ports, not complete replacements for Expo/Unistyles. |
|
||||
| Shared app edits | One capability gate for alpha-mask shimmer. GPUI shows the logo and labels without that effect. Existing web/iOS/Android paths keep their behavior. |
|
||||
| Native tests and this guide | Make the working slice reproducible and give contributors bounded follow-up work. |
|
||||
|
||||
Upstream examples, its tester/testing package, website, unused fonts/vector-icon
|
||||
ports, starter navigation dependencies, sample icon, and unused nested Cargo config
|
||||
are omitted. Build outputs, native binaries, downloaded SDKs, logs, screenshots,
|
||||
and the exploratory size-measurement copy stay out of Git.
|
||||
|
||||
The retained runtime still includes its build machinery, platform backends,
|
||||
headers, and tests. Arbitrarily deleting those to reduce line count makes an
|
||||
upstream refresh harder. Vendoring can later be replaced by a pinned dependency
|
||||
once the local patches are upstreamed and distribution is reproducible.
|
||||
|
||||
### Editing the runtime
|
||||
|
||||
Edit `vendor/react-native-gpui`, then run `npm run gpui:install` before rebuilding.
|
||||
npm copies local packages rather than symlinking them; the install command
|
||||
refreshes those copies even when their versions have not changed.
|
||||
|
||||
There are two local renderer changes:
|
||||
|
||||
- `node_view.rs`: a `pointerEvents="box-none"` view must not install its own hit
|
||||
area. Before the patch, Paseo's empty portal overlay intercepted sidebar clicks.
|
||||
- `StatusBar.desktop.js`: desktop windows do not have the mobile OS status bar.
|
||||
|
||||
GPUI remains a pinned Cargo git dependency. Upstream tooling downloads Hermes
|
||||
and React Native prebuilt libraries. The snapshot is not an offline or fully
|
||||
source-built distribution, and release packaging has not been verified.
|
||||
|
||||
## Working slice and limits
|
||||
|
||||
Verified on Linux x86_64: all 24 app routes in the bundle, connection to the dev
|
||||
daemon, the real project/workspace sidebar, opening an existing workspace,
|
||||
existing message rendering, and native composer typing/deletion. The native
|
||||
regression test restores the draft and does not send a message. Rust tests cover
|
||||
SHA-256/random-byte bounds and real SQLite parameter binding/transaction rollback.
|
||||
|
||||
The following are not feature-parity claims:
|
||||
|
||||
| Area | Current state |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Styling | Adapter evaluates existing Unistyles theme/style factories and breakpoints. No Nitro shadow-tree integration. Full theme/resize behavior needs QA. |
|
||||
| Storage/crypto/localization | Native filesystem + SQLite + SHA-256 + OS randomness. Intl ships English locale data and IANA time zones. The Rust service contains Linux-specific OS access. |
|
||||
| Clipboard/files | Text clipboard, native file dialogs, PNG/JPEG picking, and download/export adapters exist. End-to-end dialog/transfer coverage is still missing. |
|
||||
| Voice/camera/push/power inhibition | Unavailable. Requests either report the missing capability or use the app's existing unavailable-capability implementation. |
|
||||
| Image conversion/image clipboard | Unavailable. The picker accepts PNG/JPEG without conversion. |
|
||||
| Mermaid/HTML/document previews | Mermaid and HTML show source. Document previews report unavailable. No embedded browser backend. |
|
||||
| Terminals | The mobile view expects an Expo `PaseoTerminal` component. No GPUI implementation yet. |
|
||||
| Diffs | The mobile surface uses React Native Skia. No GPUI drawing backend yet. |
|
||||
| Plugins | The existing loader evaluates JS and injects host React/RN. No end-to-end plugin verification on GPUI yet. |
|
||||
| Other platforms | The upstream runtime targets macOS/Windows too; this Paseo integration has only been exercised on Linux. |
|
||||
|
||||
The initial stripped development executable was about 68 MiB, with about 10 MiB
|
||||
of Hermes/JSI libraries and a 21 MiB JS bundle, approximately 100 MiB uncompressed.
|
||||
That measurement excludes the daemon, is not an optimized release installer, and
|
||||
does not establish memory usage or a size advantage over another renderer.
|
||||
|
||||
## Experiments to pick up
|
||||
|
||||
Each experiment should preserve the real app and demonstrate a user workflow.
|
||||
|
||||
1. **Native terminal.** Implement a GPUI component for the existing
|
||||
[`PaseoTerminalView`](../modules/paseo-terminal/index.tsx) frame contract.
|
||||
Keep the JS headless xterm emulator, transport, and frame generation initially.
|
||||
Verify resize/replay, keyboard/IME/paste, scrollback/selection, and the existing
|
||||
Unicode corpus. [Terminal rendering](../../../docs/terminal-rendering.md)
|
||||
explains why shaping must preserve xterm's cell columns.
|
||||
2. **Diff surface.** Keep `src/git/diff-document`'s model and review actions;
|
||||
add GPUI text measurement and painting instead of the Skia surface. Test
|
||||
unified/split views, wrapping, scrolling, selection, and inline reviews on
|
||||
large real diffs. The [C++ layout experiment](../modules/paseo-diff-prototype/README.md)
|
||||
is a separate candidate for shared geometry; it is not a finished renderer.
|
||||
3. **Plugins.** Load a small plugin into an isolated daemon, open its real screen
|
||||
or workspace panel, call a plugin RPC, and reload it. Verify subscriptions and
|
||||
cleanup. Track unsupported services explicitly instead of adding fake APIs.
|
||||
4. **Native services and styling.** Exercise dialogs, downloads, clipboard,
|
||||
persistence/restart, theme switching, resizing, and focus. Replace the narrow
|
||||
adapters with complete implementations only where the app needs them.
|
||||
5. **Portability and packaging.** Build on clean Linux, then macOS/Windows. Replace
|
||||
Linux-only randomness/timezone access, check font/shaping and IME behavior, and
|
||||
verify relocatable libraries/assets. Measure a release build and a controlled
|
||||
workflow before claiming a performance or size improvement.
|
||||
6. **Bundled daemon, independently.** First run the unchanged daemon under Bun
|
||||
in isolation. Verify PTY/native dependencies, terminal-worker subprocesses,
|
||||
provider launches, and dynamic plugins. Then compare standalone/AOT packaging
|
||||
and size. This branch neither ports the daemon nor embeds it in the UI process.
|
||||
|
||||
A Rust/GPUI terminal or diff painter can share desktop implementation across the
|
||||
upstream renderer's supported OSes. Reusing it on mobile still requires mobile
|
||||
bindings and graphics integration; choosing Rust or C++ alone does not provide that.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the normal checks from the root. GPUI adapters are excluded from the mobile
|
||||
TypeScript project; its typecheck alone does not validate this target.
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
For the native UI regression, use a daemon with an existing workspace and build
|
||||
with the driver. Run the native app on an isolated display when testing on a
|
||||
machine in use:
|
||||
|
||||
```sh
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run build:gpui -- --features test-driver,visual-capture
|
||||
RN_GPUI_TEST_DRIVER=1 RN_GPUI_TEST_DRIVER_TOKEN=local-test \
|
||||
RN_GPUI_DEV_MODE=0 RN_GPUI_BUNDLE="$PWD/.artifacts/gpui/paseo.bundle" \
|
||||
RN_GPUI_ASSETS_DIR="$PWD/.artifacts/gpui/assets/assets" \
|
||||
xvfb-run -a -s '-screen 0 2560x1600x24' \
|
||||
packages/app/gpui/desktop/target/debug/react-native-gpui-paseo-gpui
|
||||
```
|
||||
|
||||
Use the printed `RN_GPUI_TEST_DRIVER_PORT` in another shell:
|
||||
|
||||
```sh
|
||||
PASEO_GPUI_TEST_URL=ws://127.0.0.1:PORT PASEO_GPUI_TEST_TOKEN=local-test \
|
||||
npx vitest run packages/app/gpui/native.test.ts --bail=1
|
||||
```
|
||||
|
||||
Without a driver URL the test is skipped. Put diagnostic logs/captures in
|
||||
`.artifacts/gpui/`; do not commit them or publish private workspace contents as QA.
|
||||
@@ -1,73 +0,0 @@
|
||||
import { promises as fs, paths } from "@react-native-gpui/fs";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
|
||||
let writes = Promise.resolve();
|
||||
const directory = () => `${paths.appData}/paseo-gpui/storage`;
|
||||
const filename = (key) => `${directory()}/${Desktop.sha256(key)}.json`;
|
||||
async function read(key) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filename(key), "utf8")).value;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function mutate(operation) {
|
||||
const result = writes.then(operation);
|
||||
writes = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
async function write(key, value) {
|
||||
await fs.mkdir(directory(), { recursive: true });
|
||||
const target = filename(key);
|
||||
await fs.writeFile(`${target}.tmp`, JSON.stringify({ key, value }), "utf8");
|
||||
await fs.rename(`${target}.tmp`, target);
|
||||
}
|
||||
async function keys() {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory());
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
return Promise.all(
|
||||
entries
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map(async (name) => JSON.parse(await fs.readFile(`${directory()}/${name}`, "utf8")).key),
|
||||
);
|
||||
}
|
||||
const storage = {
|
||||
async getItem(key) {
|
||||
await writes;
|
||||
return read(key);
|
||||
},
|
||||
setItem(key, value) {
|
||||
return mutate(() => write(key, value));
|
||||
},
|
||||
removeItem(key) {
|
||||
return mutate(() => fs.rm(filename(key), { force: true }));
|
||||
},
|
||||
async getAllKeys() {
|
||||
await writes;
|
||||
return keys();
|
||||
},
|
||||
async multiGet(items) {
|
||||
await writes;
|
||||
return Promise.all(items.map(async (key) => [key, await read(key)]));
|
||||
},
|
||||
multiSet(items) {
|
||||
return mutate(async () => {
|
||||
for (const [key, value] of items) await write(key, value);
|
||||
});
|
||||
},
|
||||
multiRemove(items) {
|
||||
return mutate(async () => {
|
||||
for (const key of items) await fs.rm(filename(key), { force: true });
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
return mutate(() => fs.rm(directory(), { recursive: true, force: true }));
|
||||
},
|
||||
};
|
||||
export default storage;
|
||||
@@ -1,21 +0,0 @@
|
||||
// Preserve the voice model's lifecycle without loading mobile-only Expo audio
|
||||
// during app startup. Requests fail explicitly until a Linux audio backend exists.
|
||||
export function createAudioEngine() {
|
||||
const unavailable = async () => {
|
||||
throw new Error("Voice capture and playback are not available in the GPUI build yet.");
|
||||
};
|
||||
return {
|
||||
initialize: unavailable,
|
||||
startCapture: unavailable,
|
||||
play: unavailable,
|
||||
async destroy() {},
|
||||
async stopCapture() {},
|
||||
stop() {},
|
||||
clearQueue() {},
|
||||
isPlaying: () => false,
|
||||
isMuted: () => false,
|
||||
toggleMute() {
|
||||
throw new Error("Voice capture is not available in the GPUI build yet.");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { TurboModuleRegistry } from "react-native";
|
||||
const clipboard = TurboModuleRegistry.getEnforcing("Clipboard");
|
||||
export const getStringAsync = () => clipboard.getString();
|
||||
export async function setStringAsync(value) {
|
||||
clipboard.setString(value);
|
||||
return true;
|
||||
}
|
||||
export async function hasStringAsync() {
|
||||
return (await getStringAsync()).length > 0;
|
||||
}
|
||||
export async function getImageAsync() {
|
||||
throw new Error("Image clipboard is not implemented for GPUI yet");
|
||||
}
|
||||
export async function setImageAsync() {
|
||||
throw new Error("Image clipboard is not implemented for GPUI yet");
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function getRandomValues(array) {
|
||||
if (
|
||||
!ArrayBuffer.isView(array) ||
|
||||
array instanceof DataView ||
|
||||
array instanceof Float32Array ||
|
||||
array instanceof Float64Array
|
||||
) {
|
||||
throw new TypeError("getRandomValues requires an integer typed array");
|
||||
}
|
||||
if (array.byteLength > 65536) throw new RangeError("Random request exceeds 65536 bytes");
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(
|
||||
Desktop.randomBytes(array.byteLength),
|
||||
);
|
||||
return array;
|
||||
}
|
||||
export function randomUUID() {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
export const CryptoDigestAlgorithm = { SHA256: "SHA-256" };
|
||||
export async function digestStringAsync(algorithm, text) {
|
||||
if (algorithm !== "SHA-256") throw new Error(`Unsupported digest algorithm: ${algorithm}`);
|
||||
return Desktop.sha256(text);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
export async function getDocumentAsync(options = {}) {
|
||||
const paths = await dialog.open({ multiple: options.multiple ?? false });
|
||||
return {
|
||||
canceled: paths.length === 0,
|
||||
assets: await Promise.all(
|
||||
paths.map(async (path) => ({
|
||||
uri: `file://${path}`,
|
||||
name: path.split("/").at(-1),
|
||||
size: (await promises.stat(path)).size,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
|
||||
|
||||
export function FileHtmlPreview({ html, testID }) {
|
||||
return (
|
||||
<ScrollView testID={testID}>
|
||||
<Text>HTML rendering is not available in GPUI. Showing source.</Text>
|
||||
<HighlightedCodeBlock code={html} language="html" />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
export function DocumentFrame({ testID }) {
|
||||
return (
|
||||
<View testID={testID}>
|
||||
<Text>Document preview is not available in the GPUI build yet.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import {
|
||||
EventEmitter,
|
||||
NativeModule,
|
||||
SharedObject,
|
||||
SharedRef,
|
||||
} from "expo-modules-core/src/polyfill/CoreModule";
|
||||
export function ensureNativeModulesAreInstalled() {
|
||||
if (globalThis.expo) return;
|
||||
globalThis.expo = { EventEmitter, NativeModule, SharedObject, SharedRef, modules: {} };
|
||||
}
|
||||
ensureNativeModulesAreInstalled();
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Linking } from "react-native";
|
||||
import { NativeModule } from "expo-modules-core/src/polyfill/CoreModule";
|
||||
|
||||
class DesktopLinking extends NativeModule {
|
||||
initialURL = null;
|
||||
subscription;
|
||||
constructor() {
|
||||
super();
|
||||
Linking.getInitialURL()
|
||||
.then((url) => {
|
||||
this.initialURL = url;
|
||||
if (url) this.emit("onURLReceived", { url });
|
||||
return url;
|
||||
})
|
||||
.catch((error) => console.error("[GPUI linking]", error));
|
||||
}
|
||||
getLinkingURL = () => this.initialURL;
|
||||
clearInitialURL() {
|
||||
this.initialURL = null;
|
||||
}
|
||||
startObserving() {
|
||||
this.subscription = Linking.addEventListener("url", (event) =>
|
||||
this.emit("onURLReceived", event),
|
||||
);
|
||||
}
|
||||
stopObserving() {
|
||||
this.subscription?.remove();
|
||||
}
|
||||
}
|
||||
export default new DesktopLinking();
|
||||
@@ -1,129 +0,0 @@
|
||||
import fs, { promises, paths } from "@react-native-gpui/fs";
|
||||
import { Buffer } from "buffer";
|
||||
const pathOf = (value) => String(value?.uri ?? value).replace(/^file:\/\//, "");
|
||||
const join = (parts) =>
|
||||
parts
|
||||
.map(pathOf)
|
||||
.join("/")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
export const cacheDirectory = `file://${paths.appCache}/paseo-gpui/`;
|
||||
export const documentDirectory = `file://${paths.appData}/paseo-gpui/`;
|
||||
export const EncodingType = { Base64: "base64", UTF8: "utf8" };
|
||||
export const Paths = { cache: cacheDirectory, document: documentDirectory };
|
||||
export async function getInfoAsync(uri) {
|
||||
try {
|
||||
const stat = await promises.stat(pathOf(uri));
|
||||
return { exists: true, isDirectory: stat.isDirectory(), size: stat.size, uri };
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return { exists: false, uri };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const makeDirectoryAsync = (uri, options = {}) =>
|
||||
promises.mkdir(pathOf(uri), { recursive: options.intermediates });
|
||||
export const copyAsync = ({ from, to }) => promises.copyFile(pathOf(from), pathOf(to));
|
||||
export const moveAsync = ({ from, to }) => promises.rename(pathOf(from), pathOf(to));
|
||||
export const readAsStringAsync = (uri, options = {}) =>
|
||||
promises.readFile(pathOf(uri), options.encoding ?? "utf8");
|
||||
export const writeAsStringAsync = (uri, contents, options = {}) =>
|
||||
promises.writeFile(pathOf(uri), contents, options.encoding ?? "utf8");
|
||||
export const deleteAsync = (uri, options = {}) =>
|
||||
promises.rm(pathOf(uri), { recursive: true, force: options.idempotent });
|
||||
export const readDirectoryAsync = (uri) => promises.readdir(pathOf(uri));
|
||||
export class File {
|
||||
constructor(...parts) {
|
||||
this.uri = `file://${join(parts)}`;
|
||||
}
|
||||
get name() {
|
||||
return pathOf(this).split("/").at(-1);
|
||||
}
|
||||
get exists() {
|
||||
return fs.existsSync(pathOf(this));
|
||||
}
|
||||
get size() {
|
||||
return fs.statSync(pathOf(this)).size;
|
||||
}
|
||||
create({ overwrite = false, intermediates = false } = {}) {
|
||||
if (intermediates)
|
||||
fs.mkdirSync(pathOf(this).split("/").slice(0, -1).join("/"), { recursive: true });
|
||||
fs.writeFileSync(pathOf(this), "", { flag: overwrite ? "w" : "wx" });
|
||||
}
|
||||
write(value) {
|
||||
fs.writeFileSync(pathOf(this), value);
|
||||
}
|
||||
delete() {
|
||||
fs.rmSync(pathOf(this));
|
||||
}
|
||||
async bytes() {
|
||||
return promises.readFile(pathOf(this));
|
||||
}
|
||||
async arrayBuffer() {
|
||||
const bytes = await this.bytes();
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
async base64() {
|
||||
return Buffer.from(await this.bytes()).toString("base64");
|
||||
}
|
||||
async text() {
|
||||
return promises.readFile(pathOf(this), "utf8");
|
||||
}
|
||||
move(destination) {
|
||||
fs.renameSync(pathOf(this), pathOf(destination));
|
||||
this.uri = destination.uri;
|
||||
}
|
||||
copy(destination) {
|
||||
fs.copyFileSync(pathOf(this), pathOf(destination));
|
||||
}
|
||||
static async downloadFileAsync(url, destination) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
const file = destination instanceof File ? destination : new File(destination);
|
||||
await promises.writeFile(pathOf(file), new Uint8Array(await response.arrayBuffer()));
|
||||
return file;
|
||||
}
|
||||
}
|
||||
export class Directory {
|
||||
constructor(...parts) {
|
||||
this.uri = `file://${join(parts)}`;
|
||||
}
|
||||
get exists() {
|
||||
return fs.existsSync(pathOf(this));
|
||||
}
|
||||
create({ intermediates = false } = {}) {
|
||||
fs.mkdirSync(pathOf(this), { recursive: intermediates });
|
||||
}
|
||||
delete() {
|
||||
fs.rmSync(pathOf(this), { recursive: true });
|
||||
}
|
||||
list() {
|
||||
return fs
|
||||
.readdirSync(pathOf(this), { withFileTypes: true })
|
||||
.map((entry) =>
|
||||
entry.isDirectory() ? new Directory(this, entry.name) : new File(this, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDownloadResumable(url, uri, options = {}, onProgress) {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
async downloadAsync() {
|
||||
const response = await fetch(url, { headers: options.headers, signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const target = pathOf(uri);
|
||||
await promises.mkdir(target.split("/").slice(0, -1).join("/"), { recursive: true });
|
||||
await promises.writeFile(`${target}.part`, bytes);
|
||||
await promises.rename(`${target}.part`, target);
|
||||
onProgress?.({ totalBytesWritten: bytes.length, totalBytesExpectedToWrite: bytes.length });
|
||||
return {
|
||||
uri,
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
},
|
||||
async cancelAsync() {
|
||||
controller.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// The GPUI picker accepts JPEG/PNG directly. Other formats require a future
|
||||
// native decoder; never claim conversion succeeded for an unsupported format.
|
||||
export const SaveFormat = { PNG: "png", JPEG: "jpeg", WEBP: "webp" };
|
||||
export const ImageManipulator = {
|
||||
manipulate() {
|
||||
throw new Error("Image conversion is not available in the GPUI build; select a PNG or JPEG.");
|
||||
},
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
// Desktop access is granted for each selection by the system file dialog.
|
||||
export const PermissionStatus = {
|
||||
GRANTED: "granted",
|
||||
DENIED: "denied",
|
||||
UNDETERMINED: "undetermined",
|
||||
};
|
||||
const permission = { status: "granted", granted: true, canAskAgain: true, expires: "never" };
|
||||
export const requestMediaLibraryPermissionsAsync = async () => permission;
|
||||
export const useMediaLibraryPermissions = () => [permission, requestMediaLibraryPermissionsAsync];
|
||||
export const getPendingResultAsync = async () => null;
|
||||
export async function launchImageLibraryAsync(options = {}) {
|
||||
const paths = await dialog.open({
|
||||
multiple: options.allowsMultipleSelection,
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg"] }],
|
||||
});
|
||||
return {
|
||||
canceled: !paths.length,
|
||||
assets: await Promise.all(
|
||||
paths.map(async (path) => ({
|
||||
uri: `file://${path}`,
|
||||
fileName: path.split("/").at(-1),
|
||||
fileSize: (await promises.stat(path)).size,
|
||||
mimeType: /\.png$/i.test(path) ? "image/png" : "image/jpeg",
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* oxlint-disable import/no-unassigned-import -- These packages install Intl and locale data by side effect. */
|
||||
// Hermes' Linux build omits Intl; install real locale algorithms before app code.
|
||||
import "@formatjs/intl-getcanonicallocales/polyfill-force.js";
|
||||
import "@formatjs/intl-locale/polyfill-force.js";
|
||||
import "@formatjs/intl-pluralrules/polyfill-force.js";
|
||||
import "@formatjs/intl-pluralrules/locale-data/en.js";
|
||||
import "@formatjs/intl-numberformat/polyfill-force.js";
|
||||
import "@formatjs/intl-numberformat/locale-data/en.js";
|
||||
import "@formatjs/intl-datetimeformat/polyfill-force.js";
|
||||
import "@formatjs/intl-datetimeformat/locale-data/en.js";
|
||||
import "@formatjs/intl-datetimeformat/add-all-tz.js";
|
||||
import "@formatjs/intl-displaynames/polyfill-force.js";
|
||||
import "@formatjs/intl-displaynames/locale-data/en.js";
|
||||
import "@formatjs/intl-segmenter/polyfill-force.js";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
Intl.DateTimeFormat.__setDefaultTimeZone(Desktop.timezone());
|
||||
@@ -1,6 +0,0 @@
|
||||
// No desktop power-inhibition lease is acquired by this backend yet.
|
||||
export const isAvailableAsync = async () => false;
|
||||
export async function activateKeepAwakeAsync() {
|
||||
throw new Error("Power inhibition is not available in the GPUI build.");
|
||||
}
|
||||
export async function deactivateKeepAwake() {}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Keyboard, ScrollView, useAnimatedValue } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
// A GPUI desktop window has no software-keyboard inset. RN still owns focus
|
||||
// and physical keyboard events; the mobile layout API observes its events.
|
||||
export function KeyboardProvider({ children }) {
|
||||
return children;
|
||||
}
|
||||
export const KeyboardAwareScrollView = ScrollView;
|
||||
export const KeyboardController = {
|
||||
isVisible: () => Keyboard.isVisible(),
|
||||
dismiss: () => Keyboard.dismiss(),
|
||||
};
|
||||
export const KeyboardEvents = { addListener: (...args) => Keyboard.addListener(...args) };
|
||||
export function useReanimatedKeyboardAnimation() {
|
||||
const height = useSharedValue(0);
|
||||
const progress = useSharedValue(0);
|
||||
useEffect(() => {
|
||||
const shown = Keyboard.addListener("keyboardDidShow", (event) => {
|
||||
height.value = -event.endCoordinates.height;
|
||||
progress.value = 1;
|
||||
});
|
||||
const hidden = Keyboard.addListener("keyboardDidHide", () => {
|
||||
height.value = 0;
|
||||
progress.value = 0;
|
||||
});
|
||||
return () => {
|
||||
shown.remove();
|
||||
hidden.remove();
|
||||
};
|
||||
}, [height, progress]);
|
||||
return { height, progress };
|
||||
}
|
||||
export function useKeyboardAnimation() {
|
||||
const height = useAnimatedValue(0);
|
||||
const progress = useAnimatedValue(0);
|
||||
return { height, progress };
|
||||
}
|
||||
export function useGenericKeyboardHandler(handlers) {
|
||||
useEffect(() => {
|
||||
const subscriptions = [
|
||||
Keyboard.addListener("keyboardDidShow", (event) => {
|
||||
const value = {
|
||||
height: event.endCoordinates.height,
|
||||
progress: 1,
|
||||
duration: event.duration,
|
||||
};
|
||||
handlers.onStart?.(value);
|
||||
handlers.onEnd?.(value);
|
||||
}),
|
||||
Keyboard.addListener("keyboardDidHide", () => {
|
||||
const value = { height: 0, progress: 0, duration: 0 };
|
||||
handlers.onStart?.(value);
|
||||
handlers.onEnd?.(value);
|
||||
}),
|
||||
];
|
||||
return () => subscriptions.forEach((subscription) => subscription.remove());
|
||||
}, [handlers]);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function getLocales() {
|
||||
const languageTag = Desktop.locale().split(".")[0].replaceAll("_", "-");
|
||||
return [{ languageTag, languageCode: languageTag.split("-")[0] }];
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
|
||||
export function MermaidFenceHost(props) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={props.inheritedStyles}>
|
||||
Diagram rendering is not available in the GPUI build yet.
|
||||
</Text>
|
||||
<HighlightedCodeBlock {...props} language="mermaid" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Buffer } from "buffer";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function randomBytes(length) {
|
||||
return Buffer.from(Desktop.randomBytes(length));
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// GPUI's native TextInput owns text paste, selection, and IME. The mobile
|
||||
// PasteInput module's image-paste extension is not available on this target.
|
||||
export { TextInput as default } from "react-native";
|
||||
@@ -1,11 +0,0 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
// Linux exports a downloaded file through the native Save dialog.
|
||||
export const isAvailableAsync = async () => true;
|
||||
export async function shareAsync(uri, options = {}) {
|
||||
const source = uri.replace(/^file:\/\//, "");
|
||||
const destination = await dialog.save({
|
||||
title: options.dialogTitle ?? "Save file",
|
||||
defaultName: source.split("/").at(-1),
|
||||
});
|
||||
if (destination) await promises.copyFile(source, destination);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
let pending = Promise.resolve();
|
||||
function enqueue(operation) {
|
||||
const result = pending.then(operation);
|
||||
pending = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
const connection = {
|
||||
async execAsync(sql) {
|
||||
Desktop.sqlite(sql, "batch");
|
||||
},
|
||||
async runAsync(sql, params = []) {
|
||||
Desktop.sqlite(sql, JSON.stringify(params));
|
||||
},
|
||||
async getAllAsync(sql, params = []) {
|
||||
return JSON.parse(Desktop.sqlite(sql, JSON.stringify(params)));
|
||||
},
|
||||
};
|
||||
export async function openDatabaseAsync(name) {
|
||||
if (name !== "paseo-replica-row-store.db") throw new Error(`Unknown GPUI database: ${name}`);
|
||||
return {
|
||||
execAsync: (...args) => enqueue(() => connection.execAsync(...args)),
|
||||
runAsync: (...args) => enqueue(() => connection.runAsync(...args)),
|
||||
getAllAsync: (...args) => enqueue(() => connection.getAllAsync(...args)),
|
||||
withExclusiveTransactionAsync: (operation) =>
|
||||
enqueue(async () => {
|
||||
await connection.execAsync("BEGIN IMMEDIATE");
|
||||
try {
|
||||
await operation(connection);
|
||||
await connection.execAsync("COMMIT");
|
||||
} catch (error) {
|
||||
await connection.execAsync("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as RN from "react-native";
|
||||
import { createUnistylesElement } from "./unistyles";
|
||||
export const View = createUnistylesElement(RN.View);
|
||||
export const Text = createUnistylesElement(RN.Text);
|
||||
export const ScrollView = createUnistylesElement(RN.ScrollView);
|
||||
export const TextInput = createUnistylesElement(RN.TextInput);
|
||||
export const Pressable = createUnistylesElement(RN.Pressable);
|
||||
export const Image = createUnistylesElement(RN.Image);
|
||||
export const ImageBackground = createUnistylesElement(RN.ImageBackground);
|
||||
export const FlatList = createUnistylesElement(RN.FlatList);
|
||||
export const SectionList = createUnistylesElement(RN.SectionList);
|
||||
export const TouchableOpacity = createUnistylesElement(RN.TouchableOpacity);
|
||||
export const TouchableHighlight = createUnistylesElement(RN.TouchableHighlight);
|
||||
export const TouchableWithoutFeedback = createUnistylesElement(RN.TouchableWithoutFeedback);
|
||||
export const ActivityIndicator = createUnistylesElement(RN.ActivityIndicator);
|
||||
export const KeyboardAvoidingView = createUnistylesElement(RN.KeyboardAvoidingView);
|
||||
export const SafeAreaView = createUnistylesElement(RN.SafeAreaView);
|
||||
export const Switch = createUnistylesElement(RN.Switch);
|
||||
export const StatusBar = RN.StatusBar;
|
||||
export const Animated = {
|
||||
...RN.Animated,
|
||||
View: createUnistylesElement(RN.Animated.View),
|
||||
Text: createUnistylesElement(RN.Animated.Text),
|
||||
ScrollView: createUnistylesElement(RN.Animated.ScrollView),
|
||||
Image: createUnistylesElement(RN.Animated.Image),
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import React, { forwardRef, useSyncExternalStore } from "react";
|
||||
import * as Native from "react-native";
|
||||
|
||||
// GPUI has Fabric but no Nitro/Unistyles shadow-tree bindings. Keep the app's
|
||||
// style factories and theme API; this backend updates subscribed React views.
|
||||
const listeners = new Set();
|
||||
const resolveStyle = Symbol("paseo.gpui.style");
|
||||
let revision = 0;
|
||||
let themes = {};
|
||||
let themeName = "dark";
|
||||
let adaptive = true;
|
||||
let breakpoints = {};
|
||||
const emit = () => {
|
||||
revision += 1;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const subscribe = (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
const snapshot = () => revision;
|
||||
const theme = () => themes[themeName];
|
||||
const runtime = () => ({
|
||||
themeName,
|
||||
colorScheme: Native.Appearance.getColorScheme() ?? "light",
|
||||
screen: Native.Dimensions.get("window"),
|
||||
insets: { top: 0, bottom: 0, left: 0, right: 0, ime: 0 },
|
||||
pixelRatio: Native.PixelRatio.get(),
|
||||
fontScale: Native.PixelRatio.getFontScale(),
|
||||
breakpoint: Object.keys(breakpoints).findLast(
|
||||
(key) => breakpoints[key] <= Native.Dimensions.get("window").width,
|
||||
),
|
||||
});
|
||||
Native.Dimensions.addEventListener("change", emit);
|
||||
Native.Appearance.addChangeListener(({ colorScheme }) => {
|
||||
if (adaptive && themes[colorScheme]) themeName = colorScheme;
|
||||
emit();
|
||||
});
|
||||
function responsive(value) {
|
||||
if (Array.isArray(value)) return value.map(responsive);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length && keys.every((key) => key in breakpoints)) {
|
||||
const width = Native.Dimensions.get("window").width;
|
||||
const key = keys
|
||||
.sort((a, b) => breakpoints[a] - breakpoints[b])
|
||||
.findLast((candidate) => breakpoints[candidate] <= width);
|
||||
return key === undefined ? undefined : responsive(value[key]);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, responsive(item)]));
|
||||
}
|
||||
function materialize(style) {
|
||||
if (Array.isArray(style)) return style.map(materialize);
|
||||
if (style && typeof style === "object" && style[resolveStyle]) return style[resolveStyle]();
|
||||
return style;
|
||||
}
|
||||
function styledProps(props) {
|
||||
const result = { ...props };
|
||||
for (const key of ["style", "contentContainerStyle"]) {
|
||||
if (typeof props[key] === "function")
|
||||
result[key] = (...args) => materialize(props[key](...args));
|
||||
else if (props[key]) result[key] = materialize(props[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export const UnistylesRuntime = {
|
||||
get themeName() {
|
||||
return themeName;
|
||||
},
|
||||
get breakpoint() {
|
||||
return runtime().breakpoint;
|
||||
},
|
||||
get colorScheme() {
|
||||
return runtime().colorScheme;
|
||||
},
|
||||
getTheme: theme,
|
||||
setTheme(name) {
|
||||
if (!themes[name]) throw new Error(`Unknown GPUI theme: ${name}`);
|
||||
themeName = name;
|
||||
emit();
|
||||
},
|
||||
updateTheme(name, update) {
|
||||
themes[name] = update(themes[name]);
|
||||
emit();
|
||||
},
|
||||
setAdaptiveThemes(value) {
|
||||
adaptive = value;
|
||||
if (value && themes[runtime().colorScheme]) themeName = runtime().colorScheme;
|
||||
emit();
|
||||
},
|
||||
};
|
||||
export const StyleSheet = {
|
||||
...Native.StyleSheet,
|
||||
configure(config) {
|
||||
themes = config.themes;
|
||||
breakpoints = config.breakpoints;
|
||||
adaptive = config.settings?.adaptiveThemes ?? false;
|
||||
themeName =
|
||||
config.settings?.initialTheme ?? (adaptive ? runtime().colorScheme : Object.keys(themes)[0]);
|
||||
emit();
|
||||
},
|
||||
create(factory) {
|
||||
const evaluate = () => (typeof factory === "function" ? factory(theme(), runtime()) : factory);
|
||||
const initial = evaluate();
|
||||
const sheet = {};
|
||||
for (const key of Object.keys(initial)) {
|
||||
const wrap = (args) => {
|
||||
const resolve = () => {
|
||||
const value = evaluate()[key];
|
||||
return responsive(typeof value === "function" ? value(...args) : value);
|
||||
};
|
||||
return Object.defineProperty({ ...resolve() }, resolveStyle, { value: resolve });
|
||||
};
|
||||
sheet[key] = typeof initial[key] === "function" ? (...args) => wrap(args) : wrap([]);
|
||||
}
|
||||
return sheet;
|
||||
},
|
||||
};
|
||||
// Existing consumers retain this API; new app code should use style factories.
|
||||
export function useUnistyles() {
|
||||
useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
return { theme: theme(), rt: runtime() };
|
||||
}
|
||||
export function withUnistyles(Component, mappings) {
|
||||
const Wrapped = forwardRef(function GpuiThemed(props, ref) {
|
||||
useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
const { uniProps, ...rest } = props;
|
||||
return React.createElement(Component, {
|
||||
...styledProps({
|
||||
...mappings?.(theme(), runtime()),
|
||||
...uniProps?.(theme(), runtime()),
|
||||
...rest,
|
||||
}),
|
||||
ref,
|
||||
});
|
||||
});
|
||||
return Wrapped;
|
||||
}
|
||||
export const createUnistylesElement = withUnistyles;
|
||||
@@ -1 +0,0 @@
|
||||
{ "name": "Paseo", "displayName": "Paseo GPUI" }
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [[require.resolve("babel-preset-expo"), { unstable_transformImportMeta: true }]],
|
||||
plugins: [[require.resolve("react-native-unistyles/plugin"), { root: "../src" }]],
|
||||
};
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
build/
|
||||
.cache/
|
||||
autolink/
|
||||
target/
|
||||
Generated
-8151
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
||||
[package]
|
||||
name = "react-native-gpui-paseo-gpui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Set CARGO_PROFILE_DEV_DEBUG=full to opt into full debug information.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
split-debuginfo = "packed"
|
||||
|
||||
# Dependencies are optimized even in a dev build: GPUI rebuilds and paints the
|
||||
# element tree on every frame, and they are only compiled once.
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
opt-level = 3
|
||||
|
||||
# The C++ bridge picks its CMake build type from this crate's opt-level, and a
|
||||
# Release build of React Native has no dev server, inspector or Fast Refresh.
|
||||
[profile.dev.package.rn-gpui-sys]
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
debug = "line-tables-only"
|
||||
# Cargo strips inside the linker, before dsymutil, so package-desktop writes the
|
||||
# dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
|
||||
strip = "none"
|
||||
split-debuginfo = "packed"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
|
||||
[build-dependencies]
|
||||
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
|
||||
serde_json = "1"
|
||||
|
||||
[features]
|
||||
visual-capture = ["react-native-gpui/visual-capture"]
|
||||
test-driver = ["react-native-gpui/test-driver"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
|
||||
rn-gpui-autolink = { path = "autolink" }
|
||||
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
|
||||
|
||||
[patch.crates-io]
|
||||
# Managed by @react-native-gpui/cli autolink; do not edit between these markers.
|
||||
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
|
||||
rn-gpui-sys = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-sys" }
|
||||
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
|
||||
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
|
||||
gpui = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
|
||||
gpui_platform = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
|
||||
# End managed by autolink.
|
||||
@@ -1,172 +0,0 @@
|
||||
fn main() {
|
||||
// Forwards the engine's Node-API exports out of this executable, which is
|
||||
// what lets a stock `.node` addon resolve them on Windows.
|
||||
//
|
||||
// A DLL records every import as a name *plus the file it comes from*, and a
|
||||
// node-gyp addon records "from `node.exe`" — Windows has no global symbol
|
||||
// scope for it to fall back on. Node's own answer is that the host process
|
||||
// exports the Node-API functions, so that is what this does: the executable
|
||||
// re-exports each of the VM's `napi_*`/`node_api_*` names, forwarded to
|
||||
// `hermesvm.dll`. Addons that use node-gyp's delay-load hook (the default)
|
||||
// then reach this process instead of looking for `node.exe` on disk.
|
||||
//
|
||||
// It is a no-op on macOS and Linux, where an addon's imports resolve against
|
||||
// whatever is already loaded and need no forwarding. Declare it once, before
|
||||
// the module list; it does not depend on anything else in this file.
|
||||
rn_gpui_module_build::forward_node_api_exports();
|
||||
|
||||
// App-local Rust TurboModules: list one entry per `src/Native*.ts` spec,
|
||||
// for example `&["src/NativeGreeting.ts"]`. Their glue is generated into
|
||||
// this crate's OUT_DIR, and main.rs registers the modules.
|
||||
rn_gpui_module_build::generate(&["src/NativePaseoDesktop.ts"]);
|
||||
|
||||
let desktop_root = std::path::PathBuf::from(
|
||||
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"),
|
||||
);
|
||||
// `app.json` names the app; `desktop/desktop.json` carries the identifier
|
||||
// and everything else the packaging step needs. The loader the CLI uses is
|
||||
// JavaScript, so this reads the same file directly and applies the two
|
||||
// defaults it would apply.
|
||||
let project_root = desktop_root
|
||||
.parent()
|
||||
.expect("desktop folder has a React Native project root");
|
||||
let app_json_path = project_root.join("app.json");
|
||||
println!("cargo:rerun-if-changed={}", app_json_path.display());
|
||||
let app_json: serde_json::Value = serde_json::from_slice(
|
||||
&std::fs::read(&app_json_path)
|
||||
.unwrap_or_else(|error| panic!("read {}: {error}", app_json_path.display())),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse {}: {error}", app_json_path.display()));
|
||||
let app_name = app_json["name"]
|
||||
.as_str()
|
||||
.filter(|name| !name.is_empty())
|
||||
.expect("app.json must define a nonempty name");
|
||||
if app_json["desktop"]
|
||||
.as_object()
|
||||
.is_some_and(|desktop| !desktop.is_empty())
|
||||
{
|
||||
panic!("move `app.json` desktop.* into `desktop/desktop.json` (see the README)");
|
||||
}
|
||||
|
||||
let desktop_json_path = project_root.join("desktop/desktop.json");
|
||||
println!("cargo:rerun-if-changed={}", desktop_json_path.display());
|
||||
let desktop_json: serde_json::Value = std::fs::read(&desktop_json_path)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let app_id = match desktop_json["identifier"].as_str() {
|
||||
Some(identifier) if !identifier.is_empty() => identifier.to_string(),
|
||||
_ => format!(
|
||||
"com.example.{}",
|
||||
app_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
),
|
||||
};
|
||||
println!("cargo:rustc-env=RN_GPUI_APP_NAME={app_name}");
|
||||
println!("cargo:rustc-env=RN_GPUI_APP_ID={app_id}");
|
||||
// Only a debug build names the bundle it was developed against. A release
|
||||
// binary must find the bundle beside its own executable, because the path
|
||||
// baked in here is where this machine happened to build it — and on the
|
||||
// machine the package is installed on, that path does not exist.
|
||||
println!("cargo:rerun-if-env-changed=PROFILE");
|
||||
if std::env::var("PROFILE").as_deref() == Ok("debug") {
|
||||
println!(
|
||||
"cargo:rustc-env=RN_GPUI_DEFAULT_BUNDLE={}",
|
||||
desktop_root.join("build/index.bundle").display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-env-changed=DEP_RN_GPUI_RUNTIME_RPATHS");
|
||||
println!("cargo:rerun-if-env-changed=RN_GPUI_PACKAGING");
|
||||
let Ok(paths) = std::env::var("DEP_RN_GPUI_RUNTIME_RPATHS") else {
|
||||
return;
|
||||
};
|
||||
let dirs = paths.split('|').filter(|path| !path.is_empty());
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
stage_shared_libraries(dirs);
|
||||
return;
|
||||
}
|
||||
// `RN_GPUI_PACKAGING` means this binary is about to be installed somewhere
|
||||
// else, so only the relative rpaths belong in it. Keeping the absolute ones
|
||||
// would let a packaged app load this build tree's libraries in preference to
|
||||
// its own on the machine that built it — which hides exactly the packaging
|
||||
// bugs the packaged run exists to catch.
|
||||
let packaging = std::env::var("RN_GPUI_PACKAGING").is_ok();
|
||||
if !packaging {
|
||||
for path in dirs {
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,{path}");
|
||||
}
|
||||
}
|
||||
emit_relocatable_rpaths(app_name);
|
||||
}
|
||||
|
||||
/// rpaths that survive being installed somewhere else.
|
||||
///
|
||||
/// The absolute paths above let a development build run from this tree. A
|
||||
/// packaged app is copied to another machine, where those paths do not exist,
|
||||
/// so a release build also carries paths relative to the executable: the
|
||||
/// layout the installers produce.
|
||||
fn emit_relocatable_rpaths(app_name: &str) {
|
||||
if std::env::var("PROFILE").as_deref() != Ok("release") {
|
||||
return;
|
||||
}
|
||||
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let mut emit = |rpath: String| println!("cargo:rustc-link-arg=-Wl,-rpath,{rpath}");
|
||||
match os.as_str() {
|
||||
// An app bundle puts the executable in Contents/MacOS and the shipped
|
||||
// libraries in Contents/Frameworks.
|
||||
"macos" => emit("@executable_path/../Frameworks".into()),
|
||||
// Linux installs the executable at `usr/bin` and ships its libraries
|
||||
// under `usr/lib`:
|
||||
// - the deb puts them in `usr/lib/<app>`;
|
||||
// - an AppImage's libraries are flat in `usr/lib`, because that is the
|
||||
// directory AppRun puts on the library path and the one linuxdeploy
|
||||
// resolves dependencies from before the image is assembled.
|
||||
// Both are covered, so one binary works in either layout.
|
||||
"linux" => {
|
||||
emit(format!("$ORIGIN/../lib/{app_name}"));
|
||||
emit("$ORIGIN/../lib".into());
|
||||
emit("$ORIGIN".into());
|
||||
}
|
||||
// Windows has no rpath: the DLLs are copied beside the executable.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows has no rpath: the loader finds a DLL next to the executable or on
|
||||
/// `PATH`, so every directory the runtime reports as holding a shared library
|
||||
/// (the Hermes binary directory in particular) is copied into the profile
|
||||
/// directory that receives this app's executable. Without this the app builds
|
||||
/// and then fails to start with "hermesvm.dll was not found".
|
||||
fn stage_shared_libraries<'a>(dirs: impl Iterator<Item = &'a str>) {
|
||||
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is set"));
|
||||
// <profile>/build/<package>-<hash>/out -> <profile>
|
||||
let Some(profile_dir) = out_dir.ancestors().nth(3) else {
|
||||
return;
|
||||
};
|
||||
for dir in dirs {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let source = entry.path();
|
||||
let is_shared_library = source
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("dll"));
|
||||
if !is_shared_library {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = source.file_name() else {
|
||||
continue;
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", source.display());
|
||||
std::fs::copy(&source, profile_dir.join(name)).unwrap_or_else(|error| {
|
||||
panic!("copy {} next to the executable: {error}", source.display())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{ "identifier": "sh.paseo.gpui" }
|
||||
@@ -1,83 +0,0 @@
|
||||
const path = require("node:path");
|
||||
const { makeMetroConfig } = require("@react-native-gpui/core/metro.config");
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
process.env.EXPO_ROUTER_APP_ROOT = path.resolve(projectRoot, "../src/app");
|
||||
const config = makeMetroConfig({ projectRoot });
|
||||
const resolveRequest = config.resolver.resolveRequest;
|
||||
config.watchFolders.push(path.resolve(projectRoot, "../../.."));
|
||||
config.transformer.unstable_allowRequireContext = true;
|
||||
config.resolver.disableHierarchicalLookup = false;
|
||||
config.resolver.blockList = /[/\\]gpui[/\\]desktop[/\\](target|build|autolink)[/\\]/;
|
||||
const nativePackages = [
|
||||
"react",
|
||||
"react-native-reanimated",
|
||||
"react-native-worklets",
|
||||
"react-native-gesture-handler",
|
||||
"react-native-screens",
|
||||
"react-native-safe-area-context",
|
||||
"react-native-svg",
|
||||
];
|
||||
|
||||
const adapters = {
|
||||
"expo-keep-awake": "keep-awake.js",
|
||||
"expo-image-picker": "image-picker.js",
|
||||
"expo-image-manipulator": "image-manipulator.js",
|
||||
"expo-sharing": "sharing.js",
|
||||
"expo-document-picker": "document-picker.js",
|
||||
"@mattermost/react-native-paste-input": "paste-input.js",
|
||||
"@react-native-async-storage/async-storage": "async-storage.js",
|
||||
"expo-localization": "localization.js",
|
||||
"expo-clipboard": "clipboard.js",
|
||||
"expo-file-system": "file-system.js",
|
||||
"expo-file-system/legacy": "file-system.js",
|
||||
"react-native-keyboard-controller": "keyboard.jsx",
|
||||
crypto: "node-crypto.js",
|
||||
"node:crypto": "node-crypto.js",
|
||||
"expo-sqlite": "sqlite.js",
|
||||
"expo-crypto": "crypto.js",
|
||||
"react-native-unistyles": "unistyles.js",
|
||||
};
|
||||
const fileAdapters = {
|
||||
"/audio/audio-engine.native.ts": "audio.js",
|
||||
"/components/markdown/fence/mermaid/host.native.tsx": "mermaid.jsx",
|
||||
"/file-panels/html-preview.tsx": "document-preview.jsx",
|
||||
"/file-panels/document-frame/index.tsx": "document-preview.jsx",
|
||||
};
|
||||
config.resolver.resolveRequest = (context, name, platform) => {
|
||||
if (adapters[name]) name = path.resolve(projectRoot, "adapters", adapters[name]);
|
||||
if (
|
||||
name.endsWith("/ensureNativeModulesAreInstalled") &&
|
||||
context.originModulePath.includes("expo-modules-core")
|
||||
)
|
||||
name = path.resolve(projectRoot, "adapters/expo-install.js");
|
||||
if (name === "./ExpoLinking" && context.originModulePath.includes("expo-linking"))
|
||||
name = path.resolve(projectRoot, "adapters/expo-linking.js");
|
||||
// Linux has no Expo mobile push-token or camera provider; reuse the app's
|
||||
// existing unavailable-capability implementations.
|
||||
if (name === "expo-notifications")
|
||||
name = path.resolve(projectRoot, "../src/fdroid/expo-notifications.ts");
|
||||
if (name === "expo-camera") name = path.resolve(projectRoot, "../src/fdroid/expo-camera.tsx");
|
||||
if (name === "expo-router/_ctx") name = path.resolve(projectRoot, "routes.js");
|
||||
if (name === "qrcode") name = "qrcode/lib/browser.js";
|
||||
if (name.startsWith("react-native-unistyles/components/native/"))
|
||||
name = path.resolve(projectRoot, "adapters/unistyles-components.js");
|
||||
if (name.startsWith("@/")) name = path.resolve(projectRoot, "../src", name.slice(2));
|
||||
if (
|
||||
name.startsWith("react-native/") ||
|
||||
nativePackages.some((pkg) => name === pkg || name.startsWith(`${pkg}/`))
|
||||
) {
|
||||
context = {
|
||||
...context,
|
||||
disableHierarchicalLookup: true,
|
||||
nodeModulesPaths: [path.join(projectRoot, "node_modules")],
|
||||
};
|
||||
}
|
||||
const result = resolveRequest(context, name, platform);
|
||||
const replacement = Object.entries(fileAdapters).find(([suffix]) =>
|
||||
result.filePath?.endsWith(suffix),
|
||||
);
|
||||
if (replacement)
|
||||
return { type: "sourceFile", filePath: path.resolve(projectRoot, "adapters", replacement[1]) };
|
||||
return result;
|
||||
};
|
||||
module.exports = config;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { TurboModuleRegistry, type TurboModule } from "react-native";
|
||||
export interface Spec extends TurboModule {
|
||||
sqlite(sql: string, params: string): string;
|
||||
locale(): string;
|
||||
timezone(): string;
|
||||
sha256(text: string): string;
|
||||
randomBytes(length: number): Array<number>;
|
||||
}
|
||||
export default TurboModuleRegistry.getEnforcing<Spec>("PaseoDesktop");
|
||||
@@ -1,29 +0,0 @@
|
||||
rn_gpui_module::include_specs!();
|
||||
|
||||
mod services;
|
||||
|
||||
fn main() {
|
||||
react_native_gpui::register_module(native_paseo_desktop_module::<services::PaseoDesktop>());
|
||||
rn_gpui_autolink::ensure_linked();
|
||||
// A debug build knows where its own bundle is; a release build does not, and
|
||||
// must not: the path would name the machine that built it. A packaged release
|
||||
// app resolves the bundle beside its executable instead.
|
||||
#[cfg(debug_assertions)]
|
||||
if std::env::var_os("RN_GPUI_BUNDLE").is_none() {
|
||||
std::env::set_var("RN_GPUI_BUNDLE", env!("RN_GPUI_DEFAULT_BUNDLE"));
|
||||
}
|
||||
let config = react_native_gpui::DesktopAppConfig::from_env_with_defaults(
|
||||
env!("RN_GPUI_APP_NAME"),
|
||||
env!("RN_GPUI_APP_ID"),
|
||||
)
|
||||
.window_title("Paseo GPUI")
|
||||
.window_size(1024.0, 768.0)
|
||||
.dev_menu(true)
|
||||
.configure_window(|options| {
|
||||
// GPUI window options are public: titlebar, resizability, display,
|
||||
// decorations, min size, and other native window behavior.
|
||||
options.is_resizable = true;
|
||||
});
|
||||
|
||||
react_native_gpui::run_with(config);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
use crate::{NativePaseoDesktopEvents, NativePaseoDesktopSpec};
|
||||
use rn_gpui_module::ModuleError;
|
||||
use std::io::Read;
|
||||
|
||||
pub struct PaseoDesktop {
|
||||
database: std::sync::Mutex<Option<rusqlite::Connection>>,
|
||||
}
|
||||
impl PaseoDesktop {
|
||||
fn database(
|
||||
&self,
|
||||
) -> Result<std::sync::MutexGuard<'_, Option<rusqlite::Connection>>, ModuleError> {
|
||||
let mut guard = self
|
||||
.database
|
||||
.lock()
|
||||
.map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
if guard.is_none() {
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
std::path::PathBuf::from(std::env::var("HOME").expect("HOME is set"))
|
||||
.join(".local/share")
|
||||
});
|
||||
let dir = base.join("paseo-gpui");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
*guard = Some(
|
||||
rusqlite::Connection::open(dir.join("replica.db"))
|
||||
.map_err(|e| ModuleError::new(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
}
|
||||
impl NativePaseoDesktopSpec for PaseoDesktop {
|
||||
fn new(_events: NativePaseoDesktopEvents) -> Self {
|
||||
Self {
|
||||
database: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
fn sqlite(&self, sql: String, params: String) -> Result<String, ModuleError> {
|
||||
let fail = |e: rusqlite::Error| ModuleError::new(e.to_string());
|
||||
let db = self.database()?;
|
||||
let db = db.as_ref().unwrap();
|
||||
if params == "batch" {
|
||||
db.execute_batch(&sql).map_err(fail)?;
|
||||
return Ok("[]".into());
|
||||
}
|
||||
let values: Vec<serde_json::Value> =
|
||||
serde_json::from_str(¶ms).map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
let values: Vec<rusqlite::types::Value> = values
|
||||
.into_iter()
|
||||
.map(|v| match v {
|
||||
serde_json::Value::Null => rusqlite::types::Value::Null,
|
||||
serde_json::Value::String(v) => rusqlite::types::Value::Text(v),
|
||||
serde_json::Value::Number(v) => v
|
||||
.as_i64()
|
||||
.map(rusqlite::types::Value::Integer)
|
||||
.unwrap_or_else(|| rusqlite::types::Value::Real(v.as_f64().unwrap())),
|
||||
_ => rusqlite::types::Value::Text(v.to_string()),
|
||||
})
|
||||
.collect();
|
||||
let mut stmt = db.prepare(&sql).map_err(fail)?;
|
||||
let names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
|
||||
let mut rows = stmt
|
||||
.query(rusqlite::params_from_iter(values))
|
||||
.map_err(fail)?;
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) = rows.next().map_err(fail)? {
|
||||
let mut object = serde_json::Map::new();
|
||||
for (index, name) in names.iter().enumerate() {
|
||||
let value = match row.get_ref(index).map_err(fail)? {
|
||||
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
|
||||
rusqlite::types::ValueRef::Integer(v) => v.into(),
|
||||
rusqlite::types::ValueRef::Real(v) => serde_json::json!(v),
|
||||
rusqlite::types::ValueRef::Text(v) => {
|
||||
String::from_utf8_lossy(v).into_owned().into()
|
||||
}
|
||||
rusqlite::types::ValueRef::Blob(_) => {
|
||||
return Err(ModuleError::new(
|
||||
"Replica store does not support blob columns",
|
||||
))
|
||||
}
|
||||
};
|
||||
object.insert(name.clone(), value);
|
||||
}
|
||||
results.push(object);
|
||||
}
|
||||
serde_json::to_string(&results).map_err(|e| ModuleError::new(e.to_string()))
|
||||
}
|
||||
fn timezone(&self) -> Result<String, ModuleError> {
|
||||
if let Ok(zone) = std::env::var("TZ") {
|
||||
if !zone.is_empty() {
|
||||
return Ok(zone.trim_start_matches(':').into());
|
||||
}
|
||||
}
|
||||
if let Ok(path) = std::fs::read_link("/etc/localtime") {
|
||||
if let Some((_, zone)) = path.to_string_lossy().split_once("/zoneinfo/") {
|
||||
return Ok(zone.into());
|
||||
}
|
||||
}
|
||||
Ok(std::fs::read_to_string("/etc/timezone")
|
||||
.map(|s| s.trim().into())
|
||||
.unwrap_or_else(|_| "UTC".into()))
|
||||
}
|
||||
fn locale(&self) -> Result<String, ModuleError> {
|
||||
Ok(["LC_ALL", "LC_MESSAGES", "LANG"]
|
||||
.iter()
|
||||
.find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty()))
|
||||
.unwrap_or_else(|| "en_US.UTF-8".into()))
|
||||
}
|
||||
fn sha256(&self, text: String) -> Result<String, ModuleError> {
|
||||
use sha2::{Digest, Sha256};
|
||||
Ok(format!("{:x}", Sha256::digest(text.as_bytes())))
|
||||
}
|
||||
fn random_bytes(&self, length: f64) -> Result<Vec<f64>, ModuleError> {
|
||||
if !length.is_finite() || length < 0.0 || length > 65536.0 || length.fract() != 0.0 {
|
||||
return Err(ModuleError::new("Invalid random byte count"));
|
||||
}
|
||||
let mut bytes = vec![0u8; length as usize];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut source| source.read_exact(&mut bytes))
|
||||
.map_err(|error| ModuleError::new(error.to_string()))?;
|
||||
Ok(bytes.into_iter().map(f64::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn service() -> PaseoDesktop {
|
||||
PaseoDesktop {
|
||||
database: std::sync::Mutex::new(Some(rusqlite::Connection::open_in_memory().unwrap())),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn crypto_matches_sha256_and_bounds_random_requests() {
|
||||
let service = service();
|
||||
assert_eq!(
|
||||
service.sha256("abc".into()).unwrap(),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
assert_eq!(service.random_bytes(32.0).unwrap().len(), 32);
|
||||
assert!(service.random_bytes(65537.0).is_err());
|
||||
assert!(service.random_bytes(-1.0).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn replica_transactions_bind_values_and_roll_back() {
|
||||
let service = service();
|
||||
service
|
||||
.sqlite(
|
||||
"CREATE TABLE rows (key TEXT PRIMARY KEY, value TEXT);".into(),
|
||||
"batch".into(),
|
||||
)
|
||||
.unwrap();
|
||||
service
|
||||
.sqlite(
|
||||
"INSERT INTO rows VALUES (?, ?)".into(),
|
||||
r#"["key","quoted ' value"]"#.into(),
|
||||
)
|
||||
.unwrap();
|
||||
service
|
||||
.sqlite(
|
||||
"BEGIN IMMEDIATE; DELETE FROM rows; ROLLBACK;".into(),
|
||||
"batch".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = service
|
||||
.sqlite("SELECT key, value FROM rows".into(), "[]".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
|
||||
serde_json::json!([{"key":"key","value":"quoted ' value"}])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/* oxlint-disable import/no-unassigned-import -- Install globals before app modules evaluate. */
|
||||
import "./adapters/intl";
|
||||
import "../src/polyfills/install-early";
|
||||
import "../src/styles/unistyles";
|
||||
import { AppRegistry } from "react-native";
|
||||
import { RootApp } from "../src/root-app";
|
||||
|
||||
// GPUI owns the native surface. Keep Paseo's full router and providers, while
|
||||
// leaving Expo's mobile registration and WebView-based dev overlay out.
|
||||
AppRegistry.registerComponent("Paseo", () => RootApp);
|
||||
@@ -1,103 +0,0 @@
|
||||
import { afterAll, beforeAll, expect, test } from "vitest";
|
||||
|
||||
// Runs against the actual Paseo bundle in the compiled GPUI app. Start with
|
||||
// RN_GPUI_TEST_DRIVER=1 and pass its loopback URL and token to this test.
|
||||
const endpoint = process.env.PASEO_GPUI_TEST_URL;
|
||||
const token = process.env.PASEO_GPUI_TEST_TOKEN;
|
||||
let socket: WebSocket;
|
||||
let nextId = 0;
|
||||
interface Node {
|
||||
tag: number;
|
||||
component: string;
|
||||
testID: string;
|
||||
text: string;
|
||||
children?: Node[];
|
||||
}
|
||||
const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
|
||||
function request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`GPUI ${method} timed out`));
|
||||
}, 15_000);
|
||||
pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value as T);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...params, token } }));
|
||||
});
|
||||
}
|
||||
function flatten(node: Node): Node[] {
|
||||
return [node, ...(node.children ?? []).flatMap(flatten)];
|
||||
}
|
||||
async function nodes() {
|
||||
return (await request<{ children: Node[] }>("tree")).children.flatMap(flatten);
|
||||
}
|
||||
beforeAll(async () => {
|
||||
if (!endpoint) return;
|
||||
if (!token) throw new Error("PASEO_GPUI_TEST_TOKEN is required");
|
||||
socket = new WebSocket(endpoint);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener("open", () => resolve(), { once: true });
|
||||
socket.addEventListener("error", () => reject(new Error("GPUI driver connection failed")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
const response = JSON.parse(String(event.data));
|
||||
const waiter = pending.get(response.id);
|
||||
if (!waiter) return;
|
||||
pending.delete(response.id);
|
||||
const error = response.error ?? response.result?.error;
|
||||
if (error) waiter.reject(new Error(JSON.stringify(error)));
|
||||
else waiter.resolve(response.result);
|
||||
});
|
||||
});
|
||||
afterAll(() => socket?.close());
|
||||
|
||||
test.skipIf(!endpoint)(
|
||||
"real Paseo portal passes workspace clicks and the native composer edits text",
|
||||
async () => {
|
||||
const workspace = (await nodes()).find((node) =>
|
||||
node.testID.startsWith("sidebar-workspace-row-"),
|
||||
);
|
||||
expect(workspace, "connected daemon must have an existing workspace").toBeDefined();
|
||||
// Regression: the upstream box-none portal hit area intercepted this click.
|
||||
expect(await request("click", { tag: workspace!.tag })).toEqual({ clicked: true });
|
||||
await expect
|
||||
.poll(async () => (await nodes()).some((node) => node.testID === "workspace-header-title"), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => (await nodes()).some((node) => node.component === "TextInput"), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(true);
|
||||
const input = (await nodes()).find((node) => node.component === "TextInput")!;
|
||||
const original = (await request<{ text: string }>("getProps", { tag: input.tag })).text;
|
||||
try {
|
||||
await request("click", { tag: input.tag });
|
||||
await request("press", { key: "ctrl-a" });
|
||||
await request("press", { key: "backspace" });
|
||||
expect(
|
||||
await request("type", { tag: input.tag, text: "GPUI native input regression" }),
|
||||
).toEqual({ typed: true });
|
||||
await expect
|
||||
.poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
|
||||
.toBe("GPUI native input regression");
|
||||
} finally {
|
||||
await request("press", { key: "ctrl-a" });
|
||||
await request("press", { key: "backspace" });
|
||||
if (original) await request("type", { tag: input.tag, text: original });
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
Generated
-6661
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "paseo-gpui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@formatjs/intl-datetimeformat": "^7.9.0",
|
||||
"@formatjs/intl-displaynames": "^7.4.0",
|
||||
"@formatjs/intl-getcanonicallocales": "^3.3.0",
|
||||
"@formatjs/intl-locale": "^5.4.1",
|
||||
"@formatjs/intl-numberformat": "^9.5.0",
|
||||
"@formatjs/intl-pluralrules": "^6.4.0",
|
||||
"@formatjs/intl-segmenter": "^12.3.0",
|
||||
"@react-native-gpui/codegen": "file:../../../vendor/react-native-gpui/packages/codegen",
|
||||
"@react-native-gpui/core": "file:../../../vendor/react-native-gpui/packages/core",
|
||||
"@react-native-gpui/fs": "file:../../../vendor/react-native-gpui/modules/fs",
|
||||
"@react-native-gpui/gesture-handler": "file:../../../vendor/react-native-gpui/modules/gesture-handler",
|
||||
"@react-native-gpui/hermes": "file:../../../vendor/react-native-gpui/packages/hermes",
|
||||
"@react-native-gpui/reanimated": "file:../../../vendor/react-native-gpui/modules/reanimated",
|
||||
"@react-native-gpui/safe-area-context": "file:../../../vendor/react-native-gpui/modules/safe-area-context",
|
||||
"@react-native-gpui/screens": "file:../../../vendor/react-native-gpui/modules/screens",
|
||||
"@react-native-gpui/svg": "file:../../../vendor/react-native-gpui/modules/svg",
|
||||
"@react-native-gpui/worklets": "file:../../../vendor/react-native-gpui/modules/worklets",
|
||||
"@react-native/assets-registry": "^0.87.1",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.87.1",
|
||||
"react-native-gesture-handler": "3.3.0",
|
||||
"react-native-reanimated": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/4.7.1-desktop.1/react-native-reanimated-4.7.1.tgz",
|
||||
"react-native-safe-area-context": "5.10.1",
|
||||
"react-native-screens": "4.28.0",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-worklets": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/worklets-0.13.0-desktop.1/react-native-worklets-0.13.0.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "20.2.0",
|
||||
"@react-native-gpui/cli": "file:../../../vendor/react-native-gpui/packages/cli",
|
||||
"@react-native/babel-preset": "0.87.1",
|
||||
"@react-native/metro-config": "0.87.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
/// <reference types="@react-native-gpui/core/types" />
|
||||
// Added by react-native-gpui. Do not edit: it is regenerated.
|
||||
@@ -1,17 +0,0 @@
|
||||
const path = require("node:path");
|
||||
module.exports = {
|
||||
// This target only launches desktop. RN CLI 20.2's built-in
|
||||
// run-ios/run-android option lists conflict with the CLI's global verbose
|
||||
// option, and this app has no native mobile projects to launch.
|
||||
commands: ["build-android", "build-ios", "log-android", "log-ios", "run-android", "run-ios"].map(
|
||||
(name) => ({
|
||||
name,
|
||||
description: `${name} is not configured for Paseo GPUI`,
|
||||
func: () => {
|
||||
throw new Error(`${name} is not configured for Paseo GPUI`);
|
||||
},
|
||||
}),
|
||||
),
|
||||
project: { desktop: {} },
|
||||
reactNativePath: path.dirname(require.resolve("react-native/package.json")),
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
// Use the existing route tree. Expo's transformer normally supplies this root
|
||||
// through a Babel caller field; the RN desktop transformer does not.
|
||||
export const ctx = require.context(
|
||||
"../src/app",
|
||||
true,
|
||||
/^(?:\.\/)(?!(?:(?:(?:.*\+api)|(?:\+(html|native-intent))))\.[tj]sx?$).*\.[tj]sx?$/,
|
||||
);
|
||||
@@ -1,3 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
profile = "minimal"
|
||||
-35
@@ -10,7 +10,6 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@@ -56,40 +55,6 @@ class TerminalRowLayoutTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
|
||||
val red = 0xffff0000.toInt()
|
||||
val green = 0xff00aa00.toInt()
|
||||
val overlay = 0x472684ff
|
||||
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
|
||||
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
|
||||
paints.selectionBackground = overlay
|
||||
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
|
||||
val actual = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
|
||||
}
|
||||
val expected = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
canvas.drawColor(green)
|
||||
canvas.drawColor(overlay)
|
||||
}
|
||||
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
|
||||
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
|
||||
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
|
||||
})
|
||||
}
|
||||
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
|
||||
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
|
||||
val actual = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
|
||||
}
|
||||
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
|
||||
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun starts(row: JSONObject, key: String): List<Int> =
|
||||
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
|
||||
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
|
||||
backgroundColor = argb(value.background)
|
||||
cursorColor = argb(value.cursor)
|
||||
paints.selectionBackground = argb(value.selectionBackground)
|
||||
paints.selectionForeground = argb(value.selectionForeground)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ class TerminalThemeRecord : Record {
|
||||
@Field val background: Double = 0.0
|
||||
@Field val cursor: Double = 0.0
|
||||
@Field val selectionBackground: Double = 0.0
|
||||
@Field val selectionForeground: Double = 0.0
|
||||
}
|
||||
|
||||
class TerminalCustomGlyphRecord : Record {
|
||||
|
||||
+21
-17
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
|
||||
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
|
||||
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
|
||||
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
|
||||
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
|
||||
* whose cells differ in color or selection is drawn once per color, clipped to those cells' columns.
|
||||
*/
|
||||
internal class TerminalRowLayout(
|
||||
val source: TerminalRowSource,
|
||||
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
|
||||
canvas.drawRenderNode(recorded)
|
||||
}
|
||||
|
||||
/** Draws backgrounds, the selection overlay, then text with its original colors. */
|
||||
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */
|
||||
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
|
||||
drawContent(canvas, row, selection, paints)
|
||||
}
|
||||
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
|
||||
val cellHeight = font.cellHeightPx.toFloat()
|
||||
val fill = paints.fill
|
||||
for (cell in 0 until cellCount) {
|
||||
val left = cellCol[cell] * cellWidth
|
||||
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
|
||||
val background = cellBackground[cell]
|
||||
if (background ushr 24 != 0) {
|
||||
fill.color = background
|
||||
canvas.drawRect(left, 0f, right, cellHeight, fill)
|
||||
}
|
||||
if (selected(row, selection, cell)) {
|
||||
fill.color = paints.selectionBackground
|
||||
canvas.drawRect(left, 0f, right, cellHeight, fill)
|
||||
}
|
||||
val background =
|
||||
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell]
|
||||
if (background ushr 24 == 0) continue
|
||||
fill.color = background
|
||||
canvas.drawRect(
|
||||
cellCol[cell] * cellWidth,
|
||||
0f,
|
||||
(cellCol[cell] + cellSpan[cell]) * cellWidth,
|
||||
cellHeight,
|
||||
fill,
|
||||
)
|
||||
}
|
||||
|
||||
val baseline = font.baselinePx.toFloat()
|
||||
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
|
||||
val paint = font.paint(cellFlags[group.firstCell])
|
||||
var first = group.firstCell
|
||||
while (first <= group.lastCell) {
|
||||
val color = cellForeground[first]
|
||||
val color = foreground(row, selection, first, paints)
|
||||
var last = first
|
||||
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
|
||||
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1
|
||||
// Only edges between differently colored cells clip; the group's outer edges may overhang.
|
||||
val clipped = first > group.firstCell || last < group.lastCell
|
||||
canvas.save()
|
||||
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
|
||||
for (cell in 0 until cellCount) {
|
||||
val glyph = cellGlyph[cell]
|
||||
val flags = cellFlags[cell]
|
||||
val foreground = cellForeground[cell]
|
||||
val foreground = foreground(row, selection, cell, paints)
|
||||
if (glyph != null) {
|
||||
fill.color = foreground
|
||||
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
|
||||
@@ -291,6 +291,9 @@ internal class TerminalRowLayout(
|
||||
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
|
||||
}
|
||||
|
||||
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
|
||||
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
|
||||
|
||||
/** The row's text as accessibility reads it. */
|
||||
fun describe(): String = source.text.trimEnd()
|
||||
}
|
||||
@@ -298,5 +301,6 @@ internal class TerminalRowLayout(
|
||||
/** Paint state shared by every row of one view. */
|
||||
internal class TerminalPaints {
|
||||
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
var selectionBackground: Int = 0x472684FF
|
||||
var selectionBackground: Int = 0xFFFFFF00.toInt()
|
||||
var selectionForeground: Int = 0xFF000000.toInt()
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface PaseoTerminalTheme {
|
||||
background: number;
|
||||
cursor: number;
|
||||
selectionBackground: number;
|
||||
selectionForeground: number;
|
||||
}
|
||||
|
||||
export interface PaseoTerminalCellMetrics {
|
||||
|
||||
@@ -24,7 +24,8 @@ final class PaseoTerminalView: ExpoView {
|
||||
private var background = TerminalColor(Double(0xff00_0000))
|
||||
private var cursorColor = TerminalColor(Double(0xffff_ffff))
|
||||
private var rowPaint = TerminalRowPaint(
|
||||
selectionBackground: TerminalColor(Double(0x4726_84ff)))
|
||||
selectionBackground: TerminalColor(Double(0xffff_ff00)),
|
||||
selectionForeground: TerminalColor(Double(0xff00_0000)))
|
||||
private var rowCache: [String: TerminalRowLayout] = [:]
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
@@ -43,7 +44,8 @@ final class PaseoTerminalView: ExpoView {
|
||||
background = TerminalColor(record.background)
|
||||
cursorColor = TerminalColor(record.cursor)
|
||||
rowPaint = TerminalRowPaint(
|
||||
selectionBackground: TerminalColor(record.selectionBackground))
|
||||
selectionBackground: TerminalColor(record.selectionBackground),
|
||||
selectionForeground: TerminalColor(record.selectionForeground))
|
||||
backgroundColor = UIColor(cgColor: background.cgColor())
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ struct TerminalThemeRecord: Record {
|
||||
@Field var background: Double = 0
|
||||
@Field var cursor: Double = 0
|
||||
@Field var selectionBackground: Double = 0
|
||||
@Field var selectionForeground: Double = 0
|
||||
}
|
||||
|
||||
struct TerminalCustomGlyphRecord: Record {
|
||||
|
||||
@@ -27,6 +27,7 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
|
||||
/// Colors a row is painted with besides its own styles.
|
||||
struct TerminalRowPaint {
|
||||
let selectionBackground: TerminalColor
|
||||
let selectionForeground: TerminalColor
|
||||
}
|
||||
|
||||
/// One row shaped once and placed on the terminal grid.
|
||||
@@ -266,33 +267,31 @@ final class TerminalRowLayout {
|
||||
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
|
||||
}
|
||||
|
||||
/// Draws cell backgrounds, the selection overlay, then text with its original colors.
|
||||
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor {
|
||||
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
|
||||
}
|
||||
|
||||
/// Draws the row with its top edge at `top`, selected cells in selection colors.
|
||||
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
|
||||
let cellWidth = font.cellWidth
|
||||
let cellHeight = font.cellHeight
|
||||
for cell in 0..<cellCount {
|
||||
let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
|
||||
let background = cellBackground[cell]
|
||||
if background.isPainted {
|
||||
context.setFillColor(background.cgColor())
|
||||
context.fill(rect)
|
||||
}
|
||||
if isSelected(cell, row: row, selection: selection) {
|
||||
context.setFillColor(paint.selectionBackground.cgColor())
|
||||
context.fill(rect)
|
||||
}
|
||||
let background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell]
|
||||
guard background.isPainted else { continue }
|
||||
context.setFillColor(background.cgColor())
|
||||
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight))
|
||||
}
|
||||
|
||||
let centerY = top + cellHeight / 2
|
||||
let baselineInTextSpace = cellHeight / 2 - font.baseline
|
||||
// A group whose cells differ in color is drawn once per color, clipped to those
|
||||
// A group whose cells differ in color or selection is drawn once per color, clipped to those
|
||||
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
|
||||
for group in groups {
|
||||
var first = group.firstCell
|
||||
while first <= group.lastCell {
|
||||
let color = cellForeground[first]
|
||||
let color = foreground(first, row: row, selection: selection, paint: paint)
|
||||
var last = first
|
||||
while last < group.lastCell && cellForeground[last + 1] == color {
|
||||
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color {
|
||||
last += 1
|
||||
}
|
||||
context.saveGState()
|
||||
@@ -316,7 +315,7 @@ final class TerminalRowLayout {
|
||||
|
||||
let baseline = top + font.baseline
|
||||
for cell in 0..<cellCount {
|
||||
let color = cellForeground[cell]
|
||||
let color = foreground(cell, row: row, selection: selection, paint: paint)
|
||||
let left = CGFloat(cellCol[cell]) * cellWidth
|
||||
let width = CGFloat(cellSpan[cell]) * cellWidth
|
||||
if let glyph = cellGlyph[cell] {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ComposerEditingSession } from "@/composer/editing-session";
|
||||
import { agentCommandsQueryKey } from "./cache";
|
||||
import {
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
} from "./use-query";
|
||||
|
||||
export type {
|
||||
AgentSlashCommand,
|
||||
DraftCommandConfig,
|
||||
DraftCommandTarget,
|
||||
AgentCommandsClient,
|
||||
} from "./use-query";
|
||||
export { fetchAgentCommands } from "./use-query";
|
||||
|
||||
type CommandQuery = ReturnType<typeof useAgentCommandsQuery>;
|
||||
const QueryContext = createContext<CommandQuery | null>(null);
|
||||
export interface CommandHints {
|
||||
names: ReadonlySet<string>;
|
||||
insert(command: string): void;
|
||||
}
|
||||
const HintsContext = createContext<CommandHints | null>(null);
|
||||
|
||||
function useLiveAgentCommands() {
|
||||
return useContext(QueryContext);
|
||||
}
|
||||
export function useAutocompleteCommands(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
}) {
|
||||
const live = useLiveAgentCommands();
|
||||
const fallback = useAgentCommandsQuery({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
enabled: !live && input.enabled,
|
||||
draftConfig: input.draftConfig,
|
||||
});
|
||||
return live ?? fallback;
|
||||
}
|
||||
export function useAgentCommandHints() {
|
||||
return useContext(HintsContext);
|
||||
}
|
||||
export function matchCommandLink(
|
||||
url: string,
|
||||
sourceType: "markdown" | "text" | "inline-code" | undefined,
|
||||
names: ReadonlySet<string>,
|
||||
): string | null {
|
||||
return (sourceType === "markdown" || sourceType === "inline-code") &&
|
||||
url.startsWith("/") &&
|
||||
names.has(url.slice(1))
|
||||
? url
|
||||
: null;
|
||||
}
|
||||
|
||||
export function AgentCommandsProvider({
|
||||
serverId,
|
||||
agentId,
|
||||
editingSession,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
editingSession: ComposerEditingSession;
|
||||
enabled: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const query = useAgentCommandsQuery({ serverId, agentId, enabled });
|
||||
const queryClient = useQueryClient();
|
||||
const namesKey = JSON.stringify(
|
||||
[...new Set(query.commands.map((command) => command.name))].sort(),
|
||||
);
|
||||
const names = useMemo<ReadonlySet<string>>(
|
||||
() => new Set(JSON.parse(namesKey) as string[]),
|
||||
[namesKey],
|
||||
);
|
||||
const insert = useCallback(
|
||||
(token: string) => {
|
||||
const commands = queryClient.getQueryData<AgentSlashCommand[]>(
|
||||
agentCommandsQueryKey({ serverId, agentId }),
|
||||
);
|
||||
if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) {
|
||||
editingSession.insertCommand(token);
|
||||
}
|
||||
},
|
||||
[agentId, editingSession, queryClient, serverId],
|
||||
);
|
||||
const hints = useMemo(() => ({ names, insert }), [names, insert]);
|
||||
return (
|
||||
<QueryContext.Provider value={enabled ? query : null}>
|
||||
<HintsContext.Provider value={enabled ? hints : null}>{children}</HintsContext.Provider>
|
||||
</QueryContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -468,7 +468,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot,
|
||||
resolveRelativeFiles: true,
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
|
||||
@@ -64,10 +64,7 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -94,10 +91,7 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -136,45 +130,3 @@ it("sends folders outside the workspace away from the workspace Files panel", as
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const lookup = Promise.withResolvers<{
|
||||
entries: Array<{ path: string; kind: "file" }>;
|
||||
error: null;
|
||||
}>();
|
||||
const queries: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
resolveRelativeFiles: true,
|
||||
client: {
|
||||
listDirectory: async () => {
|
||||
throw new Error("Not a directory");
|
||||
},
|
||||
getDirectorySuggestions: async ({ query }: { query: string }) => {
|
||||
queries.push(query);
|
||||
return lookup.promise;
|
||||
},
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const earlier = opener.open(
|
||||
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
|
||||
"side",
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(queries).toEqual(["schema/x.md"]);
|
||||
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
|
||||
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
|
||||
await earlier;
|
||||
expect(opened).toEqual(["guide.md"]);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type OpenFileDisposition,
|
||||
} from "@/workspace/file-open";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
interface PathOpenInput {
|
||||
file?: string;
|
||||
@@ -51,11 +50,7 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
|
||||
|
||||
export function createInlinePathOpener(input: {
|
||||
workspaceRoot: string;
|
||||
client: {
|
||||
listDirectory(root: string, path: string): Promise<unknown>;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
} | null;
|
||||
resolveRelativeFiles?: boolean;
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
@@ -78,21 +73,10 @@ export function createInlinePathOpener(input: {
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const resolvedTarget =
|
||||
input.resolveRelativeFiles && input.client
|
||||
? await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
|
||||
})
|
||||
: target;
|
||||
if (ownGeneration !== generation) return;
|
||||
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
|
||||
if (!resolved?.file) return;
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: resolved.file,
|
||||
lineStart: resolvedTarget.lineStart,
|
||||
lineEnd: resolvedTarget.lineEnd,
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (location) input.openFile(location, disposition);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
getAssistantFileLinkToken,
|
||||
resolveMissingNestedRelativeFile,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
@@ -425,174 +424,6 @@ describe("fetchDaemonResolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMissingNestedRelativeFile", () => {
|
||||
const target = {
|
||||
raw: "schema/x.md:12-14",
|
||||
path: "/Users/test/project/schema/x.md",
|
||||
lineStart: 12,
|
||||
lineEnd: 14,
|
||||
};
|
||||
|
||||
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "/Users/test/project",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
...target,
|
||||
path: "/Users/test/project/repo-a/schema/x.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the exact workspace path when suggestions return that relative path", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when suggestions are empty", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({});
|
||||
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when the daemon throws", async () => {
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions: unavailableSuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
|
||||
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
|
||||
["a home-relative path", { ...target, path: "~/schema/x.md" }],
|
||||
])("does not search %s", async (_label, candidate) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: candidate,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(candidate);
|
||||
});
|
||||
|
||||
it("does not search when the workspace root is blank", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: " ",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["C:/repo/schema/x.md", "C:/repo"],
|
||||
["\\\\server\\share\\schema\\x.md", "//server/share"],
|
||||
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: absoluteTarget,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(absoluteTarget);
|
||||
expect(searches).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
|
||||
"keeps folder href %s direct",
|
||||
async (raw) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
|
||||
});
|
||||
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: folderTarget,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(folderTarget);
|
||||
expect(searches).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes a Windows workspace root before the suffix lookup", async () => {
|
||||
const windowsTarget = {
|
||||
raw: "schema/x.md",
|
||||
path: "C:\\repo\\schema\\x.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
};
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: windowsTarget,
|
||||
workspaceRoot: "C:\\repo\\",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "C:\\repo\\",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantFileLinkToken", () => {
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -106,62 +105,6 @@ export async function fetchDaemonResolution({
|
||||
};
|
||||
}
|
||||
|
||||
/** Only file-looking paths nested inside the workspace need suffix search. */
|
||||
function nestedRelativeLookupQuery(
|
||||
targetPath: string,
|
||||
workspaceRoot: string | undefined,
|
||||
): string | null {
|
||||
const root = workspaceRoot?.trim();
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = targetPath.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
if (!normalizedPath.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const relative = normalizedPath.slice(prefix.length);
|
||||
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
|
||||
return null;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
|
||||
* missing, ask the same suffix lookup bare names already use. An exact hit
|
||||
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
|
||||
* Lookup failure keeps the original target.
|
||||
*/
|
||||
export async function resolveMissingNestedRelativeFile(input: {
|
||||
target: InlinePathTarget;
|
||||
workspaceRoot?: string;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}): Promise<InlinePathTarget> {
|
||||
if (
|
||||
isAbsoluteInlineCodeToken(input.target.raw) ||
|
||||
input.target.raw.startsWith("~/") ||
|
||||
input.target.raw.endsWith("/")
|
||||
) {
|
||||
return input.target;
|
||||
}
|
||||
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
|
||||
if (!query) {
|
||||
return input.target;
|
||||
}
|
||||
try {
|
||||
return await fetchDaemonResolution({
|
||||
ambiguousQuery: query,
|
||||
token: input.target.raw,
|
||||
target: input.target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.getDirectorySuggestions,
|
||||
});
|
||||
} catch {
|
||||
return input.target;
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
@@ -272,7 +215,11 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
}
|
||||
|
||||
function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
|
||||
return (
|
||||
token.startsWith("/") ||
|
||||
token.toLowerCase().startsWith("file://") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token)
|
||||
);
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { DictationStatus } from "@/hooks/use-dictation";
|
||||
import type { DictationEmptyReason } from "@/dictation/result";
|
||||
|
||||
interface DictationControlsProps {
|
||||
volume: number;
|
||||
@@ -24,6 +26,30 @@ interface DictationControlsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_REASON_MESSAGE_KEYS = {
|
||||
"no-audio": "message.dictation.empty.noAudio",
|
||||
"no-transcript": "message.dictation.empty.noTranscript",
|
||||
} as const satisfies Record<DictationEmptyReason, string>;
|
||||
|
||||
// The line under the meter once an attempt has finished without text.
|
||||
function resolveResultText(input: {
|
||||
t: TFunction;
|
||||
status: DictationStatus;
|
||||
errorText?: string;
|
||||
emptyReason?: DictationEmptyReason | null;
|
||||
}): string | null {
|
||||
const { t, status, errorText, emptyReason } = input;
|
||||
if (status === "empty") {
|
||||
return t(EMPTY_REASON_MESSAGE_KEYS[emptyReason ?? "no-transcript"]);
|
||||
}
|
||||
if (status !== "failed") {
|
||||
return null;
|
||||
}
|
||||
return errorText
|
||||
? t("message.dictation.failed", { error: errorText })
|
||||
: t("message.dictation.failedRetry");
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
@@ -147,18 +173,26 @@ export function DictationOverlay({
|
||||
isProcessing,
|
||||
status,
|
||||
errorText,
|
||||
emptyReason,
|
||||
onCancel,
|
||||
onAccept,
|
||||
onAcceptAndSend,
|
||||
onRetry,
|
||||
onDiscard,
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & {
|
||||
errorText?: string;
|
||||
emptyReason?: DictationEmptyReason | null;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isFailed = status === "failed";
|
||||
const isEmpty = status === "empty";
|
||||
// An empty result is a finished attempt the user has to acknowledge, like a failure.
|
||||
const isFailed = status === "failed" || isEmpty;
|
||||
const canRetry = !isEmpty || emptyReason !== "no-audio";
|
||||
const showActiveState = isRecording || isProcessing || isFailed;
|
||||
const actionsDisabled = isProcessing;
|
||||
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
|
||||
const resultText = resolveResultText({ t, status, errorText, emptyReason });
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [overlayStyles.container, { backgroundColor: theme.colors.accent }],
|
||||
@@ -202,26 +236,26 @@ export function DictationOverlay({
|
||||
</Pressable>
|
||||
|
||||
<View style={overlayStyles.centerContainer}>
|
||||
<View style={overlayStyles.meterRow}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.accentForeground}
|
||||
/>
|
||||
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
{!resultText ? (
|
||||
<View style={overlayStyles.meterRow}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.accentForeground}
|
||||
/>
|
||||
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{status === "paused" ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
{t("message.dictation.paused")}
|
||||
</Text>
|
||||
) : null}
|
||||
{isFailed ? (
|
||||
{resultText ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
{errorText
|
||||
? t("message.dictation.failed", { error: errorText })
|
||||
: t("message.dictation.failedRetry")}
|
||||
{resultText}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -232,7 +266,7 @@ export function DictationOverlay({
|
||||
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
|
||||
</View>
|
||||
) : null}
|
||||
{!actionsDisabled && isFailed ? (
|
||||
{!actionsDisabled && isFailed && canRetry ? (
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -35,15 +35,11 @@ export interface MarkdownImageContext {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export function useMarkdownSource(
|
||||
text: string,
|
||||
context: MarkdownImageContext,
|
||||
commandNames?: ReadonlySet<string>,
|
||||
) {
|
||||
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
|
||||
const occurrenceKey = useId();
|
||||
const prepared = useMemo(
|
||||
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
|
||||
[text, context.workspaceRoot, commandNames],
|
||||
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }),
|
||||
[text, context.workspaceRoot],
|
||||
);
|
||||
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
||||
const acquisitions = sources.map((source) => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
||||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||||
import { matchCommandLink } from "@/agent-commands";
|
||||
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||||
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
||||
@@ -15,28 +13,6 @@ import { createMarkdownStyles } from "./styles";
|
||||
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
|
||||
it("recognizes complete known commands in inline code", () => {
|
||||
const names = new Set(["code-review", "review:fast"]);
|
||||
const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, {
|
||||
commandNames: names,
|
||||
});
|
||||
expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true);
|
||||
expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe(
|
||||
prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"),
|
||||
);
|
||||
const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names });
|
||||
expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true);
|
||||
expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review");
|
||||
});
|
||||
|
||||
it("resolves authored root-relative links only when the target is a known command", () => {
|
||||
const names = new Set(["code-review"]);
|
||||
expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review");
|
||||
expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull();
|
||||
expect(matchCommandLink("/code-review", "text", names)).toBeNull();
|
||||
expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull();
|
||||
});
|
||||
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useAssistantFileLinkEnvironment,
|
||||
} from "@/assistant-file-links";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -49,7 +48,6 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
selectable?: boolean;
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
agentCommands?: boolean;
|
||||
}
|
||||
|
||||
/** All Markdown surfaces share the same engine and source adaptation. */
|
||||
@@ -61,21 +59,15 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
|
||||
() => splitStreamingMarkdown(completeLines, true).head.length,
|
||||
[completeLines],
|
||||
);
|
||||
const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart;
|
||||
if (!streaming || boundary === 0) return <Part {...props} />;
|
||||
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
|
||||
return (
|
||||
<MarkdownBlockStack>
|
||||
<Part {...props} text={props.text.slice(0, boundary)} />
|
||||
<Part {...props} text={props.text.slice(boundary)} />
|
||||
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
|
||||
<MarkdownPart {...props} text={props.text.slice(boundary)} />
|
||||
</MarkdownBlockStack>
|
||||
);
|
||||
});
|
||||
|
||||
const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) {
|
||||
const commandHints = useAgentCommandHints();
|
||||
return <MarkdownPart {...props} commandHints={commandHints} />;
|
||||
});
|
||||
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
compact = false,
|
||||
@@ -83,10 +75,8 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
selectable = true,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
agentCommands: _agentCommands,
|
||||
commandHints = null,
|
||||
...imageContext
|
||||
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
|
||||
}: MarkdownRendererProps) {
|
||||
const environment = useAssistantFileLinkEnvironment();
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const client =
|
||||
@@ -94,23 +84,14 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
(environment.client?.readFile
|
||||
? { readFile: environment.client.readFile.bind(environment.client) }
|
||||
: undefined);
|
||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(
|
||||
text,
|
||||
{
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
},
|
||||
commandHints?.names,
|
||||
);
|
||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
});
|
||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
||||
const command = commandHints && matchCommandLink(url, sourceType, commandHints.names);
|
||||
if (command) {
|
||||
commandHints.insert(command);
|
||||
return;
|
||||
}
|
||||
const source = linkSource(url, sourceType);
|
||||
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
||||
else actions.open(source, "preferred");
|
||||
|
||||
@@ -56,11 +56,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
};
|
||||
}
|
||||
|
||||
function collectInlineCodePaths(
|
||||
source: string,
|
||||
workspaceRoot?: string,
|
||||
commandNames?: ReadonlySet<string>,
|
||||
): ReadonlySet<string> {
|
||||
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
|
||||
const paths = new Set<string>();
|
||||
const previousEnds = new Map<number, number>();
|
||||
// Collect candidates between consecutive runs of equal length. The engine owns
|
||||
@@ -78,11 +74,7 @@ function collectInlineCodePaths(
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
||||
if (
|
||||
(classification && classification.kind !== "external") ||
|
||||
(value.startsWith("/") && commandNames?.has(value.slice(1)))
|
||||
)
|
||||
paths.add(value);
|
||||
if (classification && classification.kind !== "external") paths.add(value);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -120,7 +112,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
|
||||
export function prepareMarkdownSource(
|
||||
source: string,
|
||||
workspaceRoot?: string,
|
||||
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
|
||||
options: { supportsSvg?: boolean } = {},
|
||||
) {
|
||||
source = normalizeMarkdownHtml(source);
|
||||
const images: MarkdownImage[] = [];
|
||||
@@ -130,9 +122,7 @@ export function prepareMarkdownSource(
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}));
|
||||
const metadata = linkMetadata(
|
||||
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
|
||||
);
|
||||
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
!source.includes("![") &&
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mut
|
||||
import { TaskListRow } from "@/components/task-list-row";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useRevealedText, type MarkdownPhase } from "@/hooks/use-revealed-text";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -1199,7 +1199,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
>
|
||||
<MarkdownRenderer
|
||||
text={revealedMessage}
|
||||
agentCommands
|
||||
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
@@ -1800,7 +1799,7 @@ function computeShimmerMetrics(input: {
|
||||
// React Native Web only observes a node when onLayout exists at mount. Keep
|
||||
// measuring while idle so a retained badge has dimensions when it starts loading.
|
||||
const shouldMeasureWebShimmer = isWeb;
|
||||
const shouldMeasureNativeShimmer = input.isLoading && isNative && supportsMaskedEffects;
|
||||
const shouldMeasureNativeShimmer = input.isLoading && isNative;
|
||||
const isNativeShimmer =
|
||||
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
|
||||
const webShimmerSpanStartX = input.labelOffsetX;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
type LayoutChangeEvent,
|
||||
PanResponder,
|
||||
type PanResponderGestureState,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
@@ -46,10 +47,13 @@ import {
|
||||
type TerminalSelectionViewport,
|
||||
} from "../terminal/native-renderer/terminal-selection";
|
||||
import {
|
||||
createTerminalSelectionController,
|
||||
terminalSelectionHandles,
|
||||
TERMINAL_SELECTION_GRIP_SIZE,
|
||||
} from "../terminal/native-renderer/terminal-selection-controller";
|
||||
TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
|
||||
advanceTerminalScrollGesture,
|
||||
classifyTerminalGestureIntent,
|
||||
resolveTerminalGestureReleaseAction,
|
||||
type TerminalGestureIntent,
|
||||
} from "../terminal/native-renderer/terminal-selection-gesture";
|
||||
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
|
||||
import {
|
||||
forwardNativeTerminalKey,
|
||||
@@ -83,6 +87,38 @@ const DEFAULT_XTERM_THEME: ITheme = {
|
||||
foreground: "#e6e6e6",
|
||||
cursor: "#e6e6e6",
|
||||
};
|
||||
interface NativeTerminalPanState {
|
||||
lastDy: number;
|
||||
rowRemainder: number;
|
||||
didScroll: boolean;
|
||||
didNavigate: boolean;
|
||||
movedBeyondTapTolerance: boolean;
|
||||
intent: TerminalGestureIntent | null;
|
||||
}
|
||||
|
||||
interface NativeTerminalSelectionPress {
|
||||
startX: number;
|
||||
startY: number;
|
||||
startedAt: number;
|
||||
startedWithSelection: boolean;
|
||||
status: "pressing" | "selecting";
|
||||
}
|
||||
|
||||
function resolvePanResponderPoint(
|
||||
press: NativeTerminalSelectionPress,
|
||||
event: GestureResponderEvent,
|
||||
gesture: PanResponderGestureState,
|
||||
): { x: number; y: number } {
|
||||
const eventDx = event.nativeEvent.locationX - press.startX;
|
||||
const eventDy = event.nativeEvent.locationY - press.startY;
|
||||
const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx;
|
||||
const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy;
|
||||
return {
|
||||
x: press.startX + dx,
|
||||
y: press.startY + dy,
|
||||
};
|
||||
}
|
||||
|
||||
function initialNativeTerminalSize(): TerminalMeasuredSize {
|
||||
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
|
||||
}
|
||||
@@ -127,14 +163,19 @@ function NativeTerminalEmulator({
|
||||
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
|
||||
const screenModelRef = useRef<TerminalScreenModel | null>(null);
|
||||
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
|
||||
const selectionControllerRef = useRef<ReturnType<
|
||||
typeof createTerminalSelectionController
|
||||
> | null>(null);
|
||||
const gestureStartRef = useRef({ x: 0, y: 0 });
|
||||
const [cellMetrics, setCellMetrics] = useState<TerminalGridCellMetrics | null>(null);
|
||||
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null);
|
||||
const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null);
|
||||
const paintFrameRef = useRef<number | null>(null);
|
||||
const outputDecoderRef = useRef(new TextDecoder());
|
||||
const panStateRef = useRef<NativeTerminalPanState>({
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
});
|
||||
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
|
||||
const lastInputModeStateRef = useRef<TerminalInputModeState>({
|
||||
...inputModeTrackerRef.current.getState(),
|
||||
@@ -204,7 +245,6 @@ function NativeTerminalEmulator({
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
selectionControllerRef.current?.cancel();
|
||||
const snapshot = selectionModelRef.current.clear();
|
||||
commitSelectionRange(snapshot.range);
|
||||
}, [commitSelectionRange]);
|
||||
@@ -215,14 +255,20 @@ function NativeTerminalEmulator({
|
||||
|
||||
const syncSelectionWithTerminal = useCallback(
|
||||
(terminal: NativeHeadlessTerminal) => {
|
||||
const previous = selectionModelRef.current.getSnapshot().range;
|
||||
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
|
||||
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
|
||||
commitSelectionRange(snapshot.range);
|
||||
},
|
||||
[commitSelectionRange],
|
||||
);
|
||||
|
||||
const clearSelectionLongPressTimeout = useCallback(() => {
|
||||
if (selectionLongPressTimeoutRef.current === null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(selectionLongPressTimeoutRef.current);
|
||||
selectionLongPressTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
|
||||
return (
|
||||
resolveMeasuredNativeTerminalSize({
|
||||
@@ -477,7 +523,8 @@ function NativeTerminalEmulator({
|
||||
emitMeasuredSize({ source: "measure" });
|
||||
return () => {
|
||||
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
|
||||
selectionControllerRef.current?.cancel();
|
||||
clearSelectionLongPressTimeout();
|
||||
selectionPressRef.current = null;
|
||||
if (paintFrameRef.current !== null) {
|
||||
cancelAnimationFrame(paintFrameRef.current);
|
||||
paintFrameRef.current = null;
|
||||
@@ -487,7 +534,7 @@ function NativeTerminalEmulator({
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [emitMeasuredSize, resetTerminal, streamKey]);
|
||||
}, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) return;
|
||||
@@ -544,7 +591,6 @@ function NativeTerminalEmulator({
|
||||
const handleCellMetricsChange = useCallback(
|
||||
(metrics: TerminalGridCellMetrics) => {
|
||||
metricsRef.current = metrics;
|
||||
setCellMetrics(metrics);
|
||||
emitMeasuredSize({ source: "measure" });
|
||||
},
|
||||
[emitMeasuredSize],
|
||||
@@ -617,6 +663,14 @@ function NativeTerminalEmulator({
|
||||
if (!coordinate) {
|
||||
return;
|
||||
}
|
||||
const existingPress = selectionPressRef.current;
|
||||
selectionPressRef.current = {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
startedAt: existingPress?.startedAt ?? Date.now(),
|
||||
startedWithSelection: existingPress?.startedWithSelection ?? false,
|
||||
status: "selecting",
|
||||
};
|
||||
const bounds = terminal.getBufferBounds();
|
||||
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
|
||||
let snapshot = selectionModelRef.current.begin({
|
||||
@@ -631,61 +685,222 @@ function NativeTerminalEmulator({
|
||||
[commitSelectionRange, resolveSelectionCoordinate],
|
||||
);
|
||||
|
||||
const selectionController = useMemo(
|
||||
() =>
|
||||
createTerminalSelectionController({
|
||||
getGeometry: () => {
|
||||
const viewport = resolveSelectionViewport();
|
||||
const metrics = metricsRef.current;
|
||||
return viewport && metrics
|
||||
? { range: selectionRangeRef.current, viewport, metrics }
|
||||
: null;
|
||||
},
|
||||
onBeginHandle: (endpoint, cols) =>
|
||||
selectionModelRef.current.beginEndpointDrag(endpoint, cols),
|
||||
onMoveHandle: (coordinate) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
commitSelectionRange(
|
||||
selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
|
||||
.range,
|
||||
);
|
||||
},
|
||||
onLongPress: beginSelectionAtPoint,
|
||||
onTap: focusTerminalFromTap,
|
||||
onScroll: applyScroll,
|
||||
schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
|
||||
cancel: (timer) => clearTimeout(timer),
|
||||
}),
|
||||
[
|
||||
applyScroll,
|
||||
beginSelectionAtPoint,
|
||||
commitSelectionRange,
|
||||
focusTerminalFromTap,
|
||||
resolveSelectionViewport,
|
||||
],
|
||||
const updateSelectionAtPoint = useCallback(
|
||||
(point: { x: number; y: number }) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal || selectionPressRef.current?.status !== "selecting") {
|
||||
return;
|
||||
}
|
||||
const coordinate = resolveSelectionCoordinate(point);
|
||||
if (!coordinate) {
|
||||
return;
|
||||
}
|
||||
const snapshot = selectionModelRef.current.update({
|
||||
coordinate,
|
||||
bounds: terminal.getBufferBounds(),
|
||||
});
|
||||
commitSelectionRange(snapshot.range);
|
||||
},
|
||||
[commitSelectionRange, resolveSelectionCoordinate],
|
||||
);
|
||||
|
||||
const handlePanResponderScroll = useCallback(
|
||||
(currentDy: number) => {
|
||||
const cellHeight = metricsRef.current?.cellHeight;
|
||||
if (!cellHeight || cellHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
const panState = panStateRef.current;
|
||||
const update = advanceTerminalScrollGesture({
|
||||
state: panState,
|
||||
currentDy,
|
||||
cellHeight,
|
||||
});
|
||||
panState.lastDy = update.state.lastDy;
|
||||
panState.rowRemainder = update.state.rowRemainder;
|
||||
panState.didScroll = update.state.didScroll;
|
||||
const { rowDelta } = update;
|
||||
if (rowDelta === 0) {
|
||||
return;
|
||||
}
|
||||
if (rowDelta > 0) {
|
||||
applyScroll("up", rowDelta);
|
||||
return;
|
||||
}
|
||||
applyScroll("down", Math.abs(rowDelta));
|
||||
},
|
||||
[applyScroll],
|
||||
);
|
||||
selectionControllerRef.current = selectionController;
|
||||
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onStartShouldSetPanResponderCapture: () => true,
|
||||
onMoveShouldSetPanResponder: (_event, gesture) => {
|
||||
const intent = classifyTerminalGestureIntent({
|
||||
status: selectionRangeRef.current ? "selecting" : "pressing",
|
||||
dx: gesture.dx,
|
||||
dy: gesture.dy,
|
||||
vx: gesture.vx,
|
||||
vy: gesture.vy,
|
||||
});
|
||||
return intent === "scroll" || intent === "select";
|
||||
},
|
||||
onMoveShouldSetPanResponderCapture: (_event, gesture) => {
|
||||
const intent = classifyTerminalGestureIntent({
|
||||
status: selectionRangeRef.current ? "selecting" : "pressing",
|
||||
dx: gesture.dx,
|
||||
dy: gesture.dy,
|
||||
vx: gesture.vx,
|
||||
vy: gesture.vy,
|
||||
});
|
||||
return intent === "scroll" || intent === "select";
|
||||
},
|
||||
onPanResponderGrant: (event) => {
|
||||
const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
|
||||
gestureStartRef.current = point;
|
||||
selectionController.press(point);
|
||||
panStateRef.current = {
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
};
|
||||
const point = {
|
||||
x: event.nativeEvent.locationX,
|
||||
y: event.nativeEvent.locationY,
|
||||
};
|
||||
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
|
||||
selectionPressRef.current = {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
startedAt: Date.now(),
|
||||
startedWithSelection: pressStatus === "selecting",
|
||||
status: pressStatus,
|
||||
};
|
||||
clearSelectionLongPressTimeout();
|
||||
if (pressStatus === "selecting") {
|
||||
return;
|
||||
}
|
||||
selectionLongPressTimeoutRef.current = setTimeout(() => {
|
||||
selectionLongPressTimeoutRef.current = null;
|
||||
const press = selectionPressRef.current;
|
||||
if (press?.status !== "pressing") {
|
||||
return;
|
||||
}
|
||||
beginSelectionAtPoint({ x: press.startX, y: press.startY });
|
||||
}, TERMINAL_GESTURE_LONG_PRESS_MS);
|
||||
},
|
||||
onPanResponderMove: (event, gesture) => {
|
||||
const selectionPress = selectionPressRef.current;
|
||||
const panState = panStateRef.current;
|
||||
if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
|
||||
panState.movedBeyondTapTolerance = true;
|
||||
}
|
||||
if (selectionPress?.status === "selecting") {
|
||||
if (panState.movedBeyondTapTolerance) {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (panState.intent === "scroll") {
|
||||
handlePanResponderScroll(gesture.dy);
|
||||
return;
|
||||
}
|
||||
if (panState.didNavigate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionPress?.status === "pressing") {
|
||||
const point = resolvePanResponderPoint(selectionPress, event, gesture);
|
||||
const dx = point.x - selectionPress.startX;
|
||||
const dy = point.y - selectionPress.startY;
|
||||
const intent = classifyTerminalGestureIntent({
|
||||
status: "pressing",
|
||||
dx,
|
||||
dy,
|
||||
vx: gesture.vx,
|
||||
vy: gesture.vy,
|
||||
elapsedMs: Date.now() - selectionPress.startedAt,
|
||||
});
|
||||
if (intent === "tap" || intent === "pending") {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelectionLongPressTimeout();
|
||||
panState.intent = intent;
|
||||
|
||||
if (intent === "scroll") {
|
||||
selectionPressRef.current = null;
|
||||
handlePanResponderScroll(gesture.dy);
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent === "select") {
|
||||
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
|
||||
updateSelectionAtPoint(point);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
onPanResponderRelease: (event, gesture) => {
|
||||
clearSelectionLongPressTimeout();
|
||||
const selectionPress = selectionPressRef.current;
|
||||
if (
|
||||
selectionPress?.status === "selecting" &&
|
||||
panStateRef.current.movedBeyondTapTolerance
|
||||
) {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
}
|
||||
selectionPressRef.current = null;
|
||||
const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current;
|
||||
panStateRef.current = {
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
};
|
||||
const releaseAction = resolveTerminalGestureReleaseAction({
|
||||
status: selectionPress?.status ?? "idle",
|
||||
startedWithSelection: selectionPress?.startedWithSelection,
|
||||
didScroll,
|
||||
didNavigate,
|
||||
movedBeyondTapTolerance,
|
||||
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
|
||||
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
|
||||
});
|
||||
if (releaseAction === "select" && selectionPress) {
|
||||
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
|
||||
return;
|
||||
}
|
||||
if (releaseAction === "focus" || releaseAction === "clear") {
|
||||
focusTerminalFromTap();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
clearSelectionLongPressTimeout();
|
||||
selectionPressRef.current = null;
|
||||
panStateRef.current = {
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
};
|
||||
},
|
||||
onPanResponderMove: (_event, gesture) =>
|
||||
selectionController.move({
|
||||
x: gestureStartRef.current.x + gesture.dx,
|
||||
y: gestureStartRef.current.y + gesture.dy,
|
||||
}),
|
||||
onPanResponderRelease: () => selectionController.release(),
|
||||
onPanResponderTerminate: () => selectionController.cancel(),
|
||||
}),
|
||||
[selectionController],
|
||||
[
|
||||
beginSelectionAtPoint,
|
||||
clearSelectionLongPressTimeout,
|
||||
focusTerminalFromTap,
|
||||
handlePanResponderScroll,
|
||||
updateSelectionAtPoint,
|
||||
],
|
||||
);
|
||||
|
||||
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
|
||||
@@ -696,11 +911,6 @@ function NativeTerminalEmulator({
|
||||
|
||||
const terminalState = terminalScreen?.viewport ?? null;
|
||||
const isScrolled = terminalScreen?.scroll.mode === "scrolled";
|
||||
const viewport = resolveSelectionViewport();
|
||||
const handles =
|
||||
viewport && cellMetrics
|
||||
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
|
||||
: [];
|
||||
const terminalGrid = terminalState ? (
|
||||
<TerminalGridView
|
||||
state={terminalState}
|
||||
@@ -717,41 +927,6 @@ function NativeTerminalEmulator({
|
||||
<View onLayout={handleLayout} style={rootStyle} testID={testId}>
|
||||
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
|
||||
{terminalGrid}
|
||||
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||
{handles.map((handle) => (
|
||||
<View key={handle.endpoint}>
|
||||
<View
|
||||
style={[
|
||||
styles.selectionStem,
|
||||
{
|
||||
left: Math.max(
|
||||
0,
|
||||
Math.min(handle.x - 1, viewport!.cols * cellMetrics!.cellWidth - 2),
|
||||
),
|
||||
top: handle.stemTop,
|
||||
height: handle.stemHeight,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Svg
|
||||
width={TERMINAL_SELECTION_GRIP_SIZE}
|
||||
height={TERMINAL_SELECTION_GRIP_SIZE}
|
||||
viewBox="0 0 18 18"
|
||||
style={[styles.selectionGrip, { left: handle.gripX - 9, top: handle.gripY - 9 }]}
|
||||
>
|
||||
<Path
|
||||
fill="#2684ff"
|
||||
d={
|
||||
handle.gripDirection === "left"
|
||||
? "M18 0H9C4 0 0 4 0 9S4 18 9 18 18 14 18 9Z"
|
||||
: "M0 0H9C14 0 18 4 18 9S14 18 9 18 0 14 0 9Z"
|
||||
}
|
||||
transform={handle.gripAbove ? "translate(0 18) scale(1 -1)" : undefined}
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<TerminalInput
|
||||
ref={inputRef}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
@@ -794,8 +969,6 @@ const styles = StyleSheet.create({
|
||||
nativeGrid: {
|
||||
flex: 1,
|
||||
},
|
||||
selectionGrip: { position: "absolute" },
|
||||
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
|
||||
followButton: {
|
||||
position: "absolute",
|
||||
right: 12,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
@@ -126,13 +126,17 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
|
||||
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
||||
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ useAgentInputDraft } = await import("./input-draft"));
|
||||
});
|
||||
const { useAgentInputDraft } = await import("./input-draft");
|
||||
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandTarget } from "@/agent-commands";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
|
||||
@@ -2,31 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
|
||||
|
||||
describe("composer editing session", () => {
|
||||
it("appends commands to the current editor buffer and focuses it", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
let text = "draft typed since publication";
|
||||
let focuses = 0;
|
||||
let selection = { start: 0, end: 0 };
|
||||
const detach = session.attachEditor({
|
||||
getText: () => text,
|
||||
replaceText: (next, nextSelection) => {
|
||||
text = next;
|
||||
selection = nextSelection;
|
||||
},
|
||||
focus: () => {
|
||||
focuses += 1;
|
||||
},
|
||||
});
|
||||
session.insertCommand("/review");
|
||||
expect(selection).toEqual({ start: text.length, end: text.length });
|
||||
session.insertCommand("/test");
|
||||
expect(text).toBe("draft typed since publication /review /test ");
|
||||
expect(focuses).toBe(2);
|
||||
expect(selection).toEqual({ start: text.length, end: text.length });
|
||||
detach();
|
||||
session.insertCommand("/ignored");
|
||||
expect(text).toBe("draft typed since publication /review /test ");
|
||||
});
|
||||
it("publishes fullscreen changes once per transition", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
const changes: boolean[] = [];
|
||||
|
||||
@@ -30,29 +30,10 @@ export interface ComposerRetainedEditor {
|
||||
*/
|
||||
export class ComposerEditingSession {
|
||||
private fullscreen = false;
|
||||
private editor: {
|
||||
getText(): string;
|
||||
replaceText(text: string, selection: ComposerSelection): void;
|
||||
focus(): void;
|
||||
} | null = null;
|
||||
private retained: ComposerRetainedEditor | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
constructor(private readonly onChange?: () => void) {}
|
||||
getFullscreen = () => this.fullscreen;
|
||||
attachEditor(editor: NonNullable<ComposerEditingSession["editor"]>) {
|
||||
this.editor = editor;
|
||||
return () => {
|
||||
if (this.editor === editor) this.editor = null;
|
||||
};
|
||||
}
|
||||
insertCommand(command: string) {
|
||||
const editor = this.editor;
|
||||
if (!editor) return;
|
||||
const text = editor.getText();
|
||||
const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `;
|
||||
editor.replaceText(nextText, { start: nextText.length, end: nextText.length });
|
||||
editor.focus();
|
||||
}
|
||||
/** Called by the unmounting editor; the next mount of this session takes it. */
|
||||
retainEditor(retained: ComposerRetainedEditor) {
|
||||
this.retained = retained;
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
} from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandTarget } from "@/agent-commands";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
|
||||
@@ -562,6 +562,7 @@ function MessageInputOverlay({
|
||||
isDictationProcessing,
|
||||
dictationStatus,
|
||||
dictationError,
|
||||
dictationEmptyReason,
|
||||
onCancelRecording,
|
||||
onAcceptRecording,
|
||||
onAcceptAndSendRecording,
|
||||
@@ -585,6 +586,7 @@ function MessageInputOverlay({
|
||||
isDictationProcessing: boolean;
|
||||
dictationStatus: React.ComponentProps<typeof DictationOverlay>["status"];
|
||||
dictationError: string | null;
|
||||
dictationEmptyReason: React.ComponentProps<typeof DictationOverlay>["emptyReason"];
|
||||
onCancelRecording: () => Promise<void>;
|
||||
onAcceptRecording: () => Promise<void>;
|
||||
onAcceptAndSendRecording: () => Promise<void>;
|
||||
@@ -593,6 +595,7 @@ function MessageInputOverlay({
|
||||
onRealtimeVoiceStop: () => void;
|
||||
}) {
|
||||
if (showDictationOverlay) {
|
||||
const awaitsAcknowledgement = dictationStatus === "failed" || dictationStatus === "empty";
|
||||
return (
|
||||
<DictationOverlay
|
||||
volume={dictationVolume}
|
||||
@@ -601,11 +604,12 @@ function MessageInputOverlay({
|
||||
isProcessing={isDictationProcessing}
|
||||
status={dictationStatus}
|
||||
errorText={dictationStatus === "failed" ? (dictationError ?? undefined) : undefined}
|
||||
emptyReason={dictationEmptyReason}
|
||||
onCancel={onCancelRecording}
|
||||
onAccept={onAcceptRecording}
|
||||
onAcceptAndSend={onAcceptAndSendRecording}
|
||||
onRetry={dictationStatus === "failed" ? onRetryFailedRecording : undefined}
|
||||
onDiscard={dictationStatus === "failed" ? onDiscardFailedRecording : undefined}
|
||||
onRetry={awaitsAcknowledgement ? onRetryFailedRecording : undefined}
|
||||
onDiscard={awaitsAcknowledgement ? onDiscardFailedRecording : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1032,7 +1036,12 @@ function computeShouldShowDictationOverlay(
|
||||
isDictationProcessing: boolean,
|
||||
dictationStatus: string,
|
||||
): boolean {
|
||||
return isDictating || isDictationProcessing || dictationStatus === "failed";
|
||||
return (
|
||||
isDictating ||
|
||||
isDictationProcessing ||
|
||||
dictationStatus === "failed" ||
|
||||
dictationStatus === "empty"
|
||||
);
|
||||
}
|
||||
|
||||
function computeIsDictationStartEnabled(
|
||||
@@ -1407,16 +1416,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
editingSession.attachEditor({
|
||||
getText: () => textInputRef.current?.getText() ?? valueRef.current,
|
||||
replaceText,
|
||||
focus: () => textInputRef.current?.focus(),
|
||||
}),
|
||||
[editingSession, replaceText],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
textInputRef.current?.focus();
|
||||
@@ -1543,6 +1542,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
duration: dictationDuration,
|
||||
error: dictationError,
|
||||
status: dictationStatus,
|
||||
emptyReason: dictationEmptyReason,
|
||||
startDictation,
|
||||
cancelDictation,
|
||||
confirmDictation,
|
||||
@@ -2190,6 +2190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isDictationProcessing={isDictationProcessing}
|
||||
dictationStatus={dictationStatus}
|
||||
dictationError={dictationError}
|
||||
dictationEmptyReason={dictationEmptyReason}
|
||||
onCancelRecording={handleCancelRecording}
|
||||
onAcceptRecording={handleAcceptRecording}
|
||||
onAcceptAndSendRecording={handleAcceptAndSendRecording}
|
||||
@@ -2211,6 +2212,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isDictationProcessing,
|
||||
dictationStatus,
|
||||
dictationError,
|
||||
dictationEmptyReason,
|
||||
handleCancelRecording,
|
||||
handleAcceptRecording,
|
||||
handleAcceptAndSendRecording,
|
||||
|
||||
@@ -25,9 +25,6 @@ export const isWeb = Platform.OS === "web";
|
||||
/** iOS or Android — the JS runtime is React Native. */
|
||||
export const isNative = Platform.OS !== "web";
|
||||
|
||||
/** Platforms with an alpha-mask compositor for decorative shimmer effects. */
|
||||
export const supportsMaskedEffects = ["web", "ios", "android"].includes(Platform.OS);
|
||||
|
||||
/** Development build/runtime — true in Metro dev bundles, false in production. */
|
||||
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./provider-snapshot-cache";
|
||||
import { queryClient as singletonQueryClient } from "./query-client";
|
||||
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||
import { agentCommandsQueryRoot } from "@/agent-commands/cache";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type {
|
||||
ListTerminalsResponse,
|
||||
MutableDaemonConfig,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { invalidateCommandsForProviderChange } from "@/agent-commands/cache";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { shareCheckoutDiff } from "@/git/diff-sharing";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
@@ -187,10 +186,10 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" };
|
||||
const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd);
|
||||
const queryKey = providersSnapshotQueryKey(input.serverId, cwd);
|
||||
const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey);
|
||||
const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey);
|
||||
let announcement: typeof snapshot | undefined = snapshot;
|
||||
void input.queryClient.cancelQueries({ queryKey, exact: true });
|
||||
const current = await input.queryClient.fetchQuery({
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
@@ -206,13 +205,12 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
});
|
||||
},
|
||||
});
|
||||
void invalidateCommandsForProviderChange({
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
cwd,
|
||||
previous: previous?.entries,
|
||||
current: current.entries,
|
||||
});
|
||||
if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) {
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||
exact: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
|
||||
|
||||
@@ -27,7 +27,7 @@ type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey>
|
||||
|
||||
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
"initialData" | "placeholderData" | "queryFn" | "staleTime"
|
||||
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
|
||||
> & {
|
||||
dataShape: "list" | "value";
|
||||
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
||||
@@ -113,7 +113,7 @@ export function fetchQueryOptions<
|
||||
dataShape,
|
||||
},
|
||||
},
|
||||
refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"),
|
||||
refetchOnMount: immutableWhen ? true : "always",
|
||||
staleTime: immutableWhen
|
||||
? (query) =>
|
||||
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { completeDictation, type DictationRecording } from "./result";
|
||||
|
||||
class RecordedAudio implements DictationRecording {
|
||||
constructor(private segments: string[]) {}
|
||||
getSegmentCount() {
|
||||
return this.segments.length;
|
||||
}
|
||||
clearAll() {
|
||||
this.segments = [];
|
||||
}
|
||||
}
|
||||
|
||||
describe("completing dictation", () => {
|
||||
it("keeps a recording through repeated empty results until a retry returns text", () => {
|
||||
const recording = new RecordedAudio(["first PCM chunk", "second PCM chunk"]);
|
||||
const empty = { finalText: " ", partialText: "", recording };
|
||||
const result = completeDictation(empty);
|
||||
expect(recording.getSegmentCount()).toBe(2);
|
||||
expect(result).toEqual({ status: "empty", reason: "no-transcript" });
|
||||
expect(completeDictation(empty)).toEqual({ status: "empty", reason: "no-transcript" });
|
||||
expect(recording.getSegmentCount()).toBe(2);
|
||||
expect(completeDictation({ ...empty, finalText: " recovered " })).toEqual({
|
||||
status: "transcribed",
|
||||
text: "recovered",
|
||||
});
|
||||
expect(recording.getSegmentCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps usable partial text when the final response is empty", () => {
|
||||
const recording = new RecordedAudio(["PCM chunk"]);
|
||||
expect(
|
||||
completeDictation({ finalText: "", partialText: " recognized words ", recording }),
|
||||
).toEqual({
|
||||
status: "transcribed",
|
||||
text: "recognized words",
|
||||
});
|
||||
expect(recording.getSegmentCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reports a missing recording without inventing a reason for the empty result", () => {
|
||||
expect(
|
||||
completeDictation({ finalText: "", partialText: "", recording: new RecordedAudio([]) }),
|
||||
).toEqual({
|
||||
status: "empty",
|
||||
reason: "no-audio",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export type DictationEmptyReason = "no-audio" | "no-transcript";
|
||||
|
||||
export interface DictationRecording {
|
||||
getSegmentCount(): number;
|
||||
clearAll(): void;
|
||||
}
|
||||
|
||||
export type DictationResult =
|
||||
| { status: "empty"; reason: DictationEmptyReason }
|
||||
| { status: "transcribed"; text: string };
|
||||
|
||||
export function completeDictation(input: {
|
||||
finalText: string;
|
||||
partialText: string;
|
||||
recording: DictationRecording;
|
||||
}): DictationResult {
|
||||
const text = input.finalText.trim() || input.partialText.trim();
|
||||
if (!text) {
|
||||
return {
|
||||
status: "empty",
|
||||
reason: input.recording.getSegmentCount() === 0 ? "no-audio" : "no-transcript",
|
||||
};
|
||||
}
|
||||
input.recording.clearAll();
|
||||
return { status: "transcribed", text };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
resolveWorkingDiffComparison,
|
||||
selectWorkingDiffComparison,
|
||||
} from "@/git/working-diff-comparison";
|
||||
import { draftAgentCommandsQueryKey } from "@/agent-commands/cache";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
|
||||
+1
-51
@@ -1,61 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { fetchQueryOptions } from "@/data/query";
|
||||
import {
|
||||
agentCommandsQueryKey,
|
||||
agentCommandsQueryRoot,
|
||||
draftAgentCommandsQueryKey,
|
||||
normalizeAgentCommandsCwd,
|
||||
sessionAgentCommandsQueryKey,
|
||||
sessionCommandQueryPolicy,
|
||||
} from "./cache";
|
||||
|
||||
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } });
|
||||
const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" });
|
||||
let calls = 0;
|
||||
let fail = false;
|
||||
let resolveRefresh: (value: string[]) => void = () => {};
|
||||
const refresh = new Promise<string[]>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
const options = fetchQueryOptions({
|
||||
...sessionCommandQueryPolicy,
|
||||
staleTimeMs: sessionCommandQueryPolicy.staleTime,
|
||||
dataShape: "value",
|
||||
queryKey: key,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
calls++;
|
||||
if (fail) throw new Error("offline");
|
||||
return refresh;
|
||||
},
|
||||
});
|
||||
client.setQueryData(key, ["old"]);
|
||||
const fresh = new QueryObserver(client, options);
|
||||
const unsubscribeFresh = fresh.subscribe(() => {});
|
||||
expect(calls).toBe(0);
|
||||
unsubscribeFresh();
|
||||
client.setQueryData(key, ["old"], { updatedAt: 1 });
|
||||
const first = new QueryObserver(client, options);
|
||||
const second = new QueryObserver(client, options);
|
||||
const unsubscribeFirst = first.subscribe(() => {});
|
||||
const unsubscribeSecond = second.subscribe(() => {});
|
||||
expect(calls).toBe(1);
|
||||
expect(first.getCurrentResult().data).toEqual(["old"]);
|
||||
expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000);
|
||||
resolveRefresh(["new"]);
|
||||
await refresh;
|
||||
await first.refetch({ cancelRefetch: false });
|
||||
expect(second.getCurrentResult().data).toEqual(["new"]);
|
||||
fail = true;
|
||||
await client.invalidateQueries({ queryKey: key });
|
||||
expect(first.getCurrentResult().data).toEqual(["new"]);
|
||||
expect(first.getCurrentResult().isError).toBe(true);
|
||||
unsubscribeFirst();
|
||||
unsubscribeSecond();
|
||||
client.clear();
|
||||
});
|
||||
} from "./agent-commands-query";
|
||||
|
||||
describe("agent command query keys", () => {
|
||||
it("shares draft commands across draft tabs with the same provider setup", () => {
|
||||
-48
@@ -1,16 +1,8 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import equal from "fast-deep-equal";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
||||
export const sessionCommandQueryPolicy = {
|
||||
staleTime: 600_000,
|
||||
gcTime: 1_800_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
export interface AgentCommandsDraftConfig {
|
||||
provider: AgentProvider;
|
||||
@@ -29,46 +21,6 @@ export function agentCommandsQueryRoot(serverId: string) {
|
||||
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
|
||||
}
|
||||
|
||||
/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial
|
||||
* discovery and unrelated providers; explicit refresh still invalidates every catalog. */
|
||||
export function invalidateCommandsForProviderChange(input: {
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
previous: readonly ProviderSnapshotEntry[] | undefined;
|
||||
current: readonly ProviderSnapshotEntry[];
|
||||
}): Promise<void> {
|
||||
const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry]));
|
||||
const current = new Map(input.current.map((entry) => [entry.provider, entry]));
|
||||
const relevant = (entry: ProviderSnapshotEntry | undefined) =>
|
||||
entry && {
|
||||
status: entry.status,
|
||||
enabled: entry.enabled,
|
||||
source: entry.source,
|
||||
models: entry.models,
|
||||
modes: entry.modes,
|
||||
defaultModeId: entry.defaultModeId,
|
||||
};
|
||||
return input.queryClient.invalidateQueries({
|
||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||
predicate: (query) => {
|
||||
const isDraft = query.queryKey[2] === "draft";
|
||||
const provider =
|
||||
query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined);
|
||||
const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined);
|
||||
if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd)
|
||||
return false;
|
||||
if (typeof provider !== "string") return false;
|
||||
const before = previous.get(provider);
|
||||
const after = current.get(provider);
|
||||
if (!before) return after?.status === "ready" && query.state.status === "error";
|
||||
if (before.status !== "ready")
|
||||
return after?.status === "ready" && query.state.status === "error";
|
||||
return !equal(relevant(before), relevant(after));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
|
||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import {
|
||||
useAutocompleteCommands,
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
type DraftCommandTarget,
|
||||
} from "@/agent-commands";
|
||||
} from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -432,7 +432,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
autocompleteCwd,
|
||||
});
|
||||
|
||||
const commandsQuery = useAutocompleteCommands({
|
||||
const commandsQuery = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||
|
||||
+5
-7
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
|
||||
import {
|
||||
type AgentCommandsClient,
|
||||
type DraftCommandConfig,
|
||||
fetchAgentCommands,
|
||||
} from "./use-agent-commands-query";
|
||||
|
||||
type ListCommands = AgentCommandsClient["listCommands"];
|
||||
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
|
||||
@@ -34,12 +38,6 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
|
||||
}
|
||||
|
||||
describe("fetchAgentCommands", () => {
|
||||
it("rejects a discovery error so a previous catalog is retained", async () => {
|
||||
const client = createClient({ ...commandsPayload([]), error: "provider unavailable" });
|
||||
await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow(
|
||||
"provider unavailable",
|
||||
);
|
||||
});
|
||||
it("loads commands for a draft composer without an agent id", async () => {
|
||||
const client = createClient(
|
||||
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
|
||||
+8
-28
@@ -1,15 +1,12 @@
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
agentCommandsQueryKey,
|
||||
sessionCommandQueryPolicy,
|
||||
type AgentCommandsDraftConfig,
|
||||
} from "./cache";
|
||||
import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query";
|
||||
|
||||
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY;
|
||||
const SESSION_COMMANDS_STALE_TIME = 60_000;
|
||||
|
||||
export interface AgentSlashCommand {
|
||||
name: string;
|
||||
@@ -44,7 +41,6 @@ export async function fetchAgentCommands(input: {
|
||||
agentId: input.agentId,
|
||||
draftConfig: input.draftConfig,
|
||||
});
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.commands as AgentSlashCommand[];
|
||||
}
|
||||
|
||||
@@ -66,16 +62,8 @@ export function useAgentCommandsQuery({
|
||||
const queryEnabled = enabled && retainedPanelActive;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { provider, cwd } = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent =
|
||||
state.sessions[serverId]?.agents?.get(agentId) ??
|
||||
state.sessions[serverId]?.agentDetails?.get(agentId);
|
||||
return { provider: agent?.provider, cwd: agent?.cwd };
|
||||
}),
|
||||
);
|
||||
|
||||
const query = useFetchQuery({
|
||||
const query = useQuery({
|
||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
@@ -84,15 +72,7 @@ export function useAgentCommandsQuery({
|
||||
return fetchAgentCommands({ client, agentId, draftConfig });
|
||||
},
|
||||
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
||||
dataShape: "value",
|
||||
meta: {
|
||||
agentCommandProvider: draftConfig?.provider ?? provider,
|
||||
agentCommandCwd: draftConfig?.cwd ?? cwd,
|
||||
},
|
||||
...(draftConfig
|
||||
? { immutableWhen: () => true as const }
|
||||
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
|
||||
subscribed: queryEnabled,
|
||||
staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||
});
|
||||
@@ -104,7 +84,7 @@ export function useAgentCommandsQuery({
|
||||
return {
|
||||
commands: query.data ?? [],
|
||||
isLoading,
|
||||
isError: query.isError && query.data === undefined,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentHistoryEntry,
|
||||
@@ -13,8 +13,18 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
}
|
||||
).__DEV__ = false;
|
||||
|
||||
const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } =
|
||||
await import("./use-agent-history");
|
||||
type UseAgentHistoryModule = typeof import("./use-agent-history");
|
||||
|
||||
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
|
||||
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
|
||||
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("./use-agent-history");
|
||||
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
|
||||
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
|
||||
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
|
||||
});
|
||||
|
||||
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
||||
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user