Compare commits

..
Author SHA1 Message Date
Mohamed Boudra acad9f6881 chore(release): cut 0.5.0-beta.5 2026-08-22 23:35:40 +02:00
Mohamed Boudra fc214b20f1 test(app): align new tab launcher expectations 2026-08-22 23:32:05 +02:00
Mohamed Boudra c998b24c5f docs: add 0.5.0-beta.5 changelog 2026-08-22 23:25:10 +02:00
Mohamed Boudra 28df3f259a fix(app): align terminal profiles header 2026-08-22 22:34:05 +02:00
Mohamed Boudra cc2012be7c fix(app): prioritize side panel launcher actions
Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation.
2026-08-22 22:24:35 +02:00
Mohamed Boudra b139046a3e fix(app): show the New agent shortcut
The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping.
2026-08-22 22:17:13 +02:00
Mohamed Boudra 482bbb2910 fix(app): order new tab launcher actions by workflow 2026-08-22 22:16:48 +02:00
Mohamed Boudra 4a5a8d469c fix(app): keep compact model options scrollable
The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms.
2026-08-22 22:06:27 +02:00
Mohamed Boudra c60fa098ac Give every pane a browser-style New tab (#3715)
* feat(app): replace empty panes with New tabs

Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.

* test(app): count content tabs in subagent integration

* fix(app): preserve workspace shortcut ownership
2026-08-22 21:30:15 +02:00
Mohamed Boudra 33cf6cf337 perf(app): defer replica persistence until user inactivity
Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.
2026-08-22 20:57:13 +02:00
Mohamed Boudra 65fbe38dd3 Restore compact and stable chat timelines (#3716)
Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position.
2026-08-22 20:50:20 +02:00
Mohamed Boudra a69dca2907 Expand workspace panel actions and keyboard navigation (#3685)
* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
2026-08-22 14:15:23 +02:00
Mohamed Boudra 3029b5991a Surface workspace changes from agent panes (#3682)
* feat(app): refine workspace pane actions

Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.

* feat(app): improve access to workspace changes
2026-08-22 12:20:21 +02:00
Mohamed Boudra b1455fb4b3 docs(hub): document zero-friction onboarding (#3657)
Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.
2026-08-22 12:15:58 +02:00
Mohamed Boudra b4e0ff7685 fix(app): restore native composer auto-growth (#3681)
Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.
2026-08-22 11:36:22 +02:00
Mohamed Boudra 4c6b79dc44 feat(app): refine agent profile creation (#3680)
Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.
2026-08-22 11:16:28 +02:00
Mohamed Boudra a687c42f33 Keep released agent SDK versions reproducible (#3678)
Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.
2026-08-22 11:10:20 +02:00
Mohamed Boudra 7beff9cb0b fix(hub): make guided setup choices actionable (#3677)
Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.
2026-08-22 10:37:19 +02:00
Mohamed Boudra c763b80824 fix(files): keep large source previews responsive (#3665)
Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
2026-08-22 10:13:31 +02:00
Mohamed Boudra 8905ad4160 fix(relay): validate handshake keys (#3669) 2026-08-22 02:32:13 +02:00
BrianAguilarWascoandMohamed Boudra 60a5344cc6 fix(server): stop short start gates from undercutting provider startup budgets (#3621)
* fix(server): stop short start gates from undercutting provider startup budgets

Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.

A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.

Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.

Related to #3571.

* test(server): drive the run start budget through a real AgentManager

The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.

Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.

* fix(server): describe agent run start timeouts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-22 02:06:16 +02:00
Mohamed Boudra 8f0c35e924 feat(hub): guide starter setup (#3651)
* feat(hub): guide setup with provider IDs

* fix(hub): preserve login setup state

* feat(hub): select starter agent runtime

Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.

* fix(hub): stage starter runtime choices

Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.

* fix(hub): scope runtime choices to workspace

Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.
2026-08-21 23:11:55 +02:00
Mohamed Boudra 956f6ea7ad fix(server): keep agent timelines in memory (#3647)
The filesystem timeline store rewrote each agent's full transcript on every buffered update, causing write amplification that grew with conversation length. Return production timelines to runtime memory and provider-history hydration while preserving the dormant storage interface for a future intentional design.
2026-08-21 18:59:09 +02:00
Mohamed Boudra bb53e7835b docs: expand related community projects 2026-08-21 18:49:05 +02:00
Mohamed Boudra 02f59b1a26 test: remove relay enable transient state assertion 2026-08-21 17:23:13 +02:00
paseo-ai[bot] 022c27a8a1 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-21 14:44:44 +00:00
179 changed files with 6852 additions and 4333 deletions
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## 0.5.0-beta.5 - 2026-08-22
### Added
- Added browser-style New tabs to every workspace pane
- Added live workspace change counts above active agent composers
- Added workspace panel and pane actions to the Command Center
- Added guided Hub setup that connects a daemon and deploys a starter workflow
### Improved
- Reduced pauses while navigating and streaming by deferring replica-cache writes until idle
- Kept large read-only source previews responsive with bounded rendering
- Kept long-running agent timelines in memory instead of rewriting their complete transcript to disk
### Fixed
- Fixed the iOS composer staying one line tall as text wraps
- Fixed compact model options becoming unreachable when profiles fill the sheet
- Fixed OpenCode agents timing out during slow startup ([#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
- Fixed chat timeline spacing and reading-position jumps when visible tool calls expand
## 0.5.0-beta.4 - 2026-08-21
### Added
+2 -2
View File
@@ -327,7 +327,7 @@ initializing → idle ⇄ running
client-side dedup; the default fetch page is 200 items.
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`. Timeline rows are runtime memory; provider history is the durable transcript authority and resumed agents rebuild from it. That storage path is derived from `cwd`, not from workspace id.
## Right-sidebar boundary: directory-backed vs workspace-owned
@@ -402,7 +402,7 @@ Providers that can accept native tool definitions should set `supportsNativePase
```
$PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
├── projects/icons/ # Custom project icon images
+5 -1
View File
@@ -83,7 +83,11 @@ Running and successful setup never seed tabs.
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
to it.
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
supporting opens: they follow the preference and focus an existing Changes tab wherever
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
hides the panel without closing the tab. A different visible Side panel tab switches to
Changes instead.
## The name
+4 -1
View File
@@ -5,6 +5,9 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
authority and repopulates those rows when an agent resumes.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
@@ -12,7 +15,7 @@ The invariants are:
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
tool payload.
+1 -1
View File
@@ -1 +1 @@
sha256-FhcGrCB3+Nsr/RCEQClXwTZW+jyRptNpNjIzAmnb3pI=
sha256-+fewwjlLlJN9VXiBOGvALNQgNxnoVXYQzBDra3ylJhA=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36140,7 +36140,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37192,12 +37192,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/server": "0.5.0-beta.4",
"@getpaseo/client": "0.5.0-beta.5",
"@getpaseo/protocol": "0.5.0-beta.5",
"@getpaseo/server": "0.5.0-beta.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37444,10 +37444,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.5",
"@getpaseo/relay": "0.5.0-beta.5",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37458,7 +37458,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37701,7 +37701,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38626,12 +38626,12 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/client": "0.5.0-beta.5",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38640,14 +38640,14 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/client": "0.5.0-beta.5",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38660,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38878,19 +38878,19 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/highlight": "0.5.0-beta.4",
"@getpaseo/plugin": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"@getpaseo/client": "0.5.0-beta.5",
"@getpaseo/highlight": "0.5.0-beta.5",
"@getpaseo/plugin": "0.5.0-beta.5",
"@getpaseo/protocol": "0.5.0-beta.5",
"@getpaseo/relay": "0.5.0-beta.5",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
@@ -39940,7 +39940,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+21 -3
View File
@@ -996,6 +996,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
});
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
const focusedBackground = await changesTab.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(focusedBackground);
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
page,
}) => {
@@ -1095,7 +1112,7 @@ test("canvas diff copies a dragged character selection without opening a review"
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
@@ -1105,7 +1122,7 @@ test("the first click after a canvas selection only dismisses the selection", as
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff replaces a selection with forward and backward drags", async ({
@@ -1537,7 +1554,8 @@ async function startReviewOnFirstChangedLine(
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await page.getByRole("button", { name: "Add review comment" }).click();
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
}
@@ -0,0 +1,58 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
function mainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function sidePanel(page: Page) {
return page.getByTestId("workspace-side-panel").filter({ visible: true });
}
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel(page)).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,132 @@
import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
function visibleMainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
title: "Composer diff stat",
repo: { withRemote: true },
});
try {
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
await writeFile(
path.join(workspace.cwd, "README.md"),
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
);
await workspace.client.checkoutRefresh(workspace.cwd);
await expect
.poll(async () => {
const workspaces = await workspace.client.fetchWorkspaces();
return (
workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null
);
})
.toEqual({ additions: 2, deletions: 0 });
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-side-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const pill = page.getByTestId("composer-diff-stat-pill");
await expect(pill).toBeVisible({ timeout: 30_000 });
await expect(pill).toContainText("+2");
await expect(pill).toContainText("-0");
await pill.click();
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await test.step("switching from another Side panel view reveals Changes", async () => {
await openFilesPanel(page);
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await pill.click();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
await pill.click();
await expect(sidePanel).toHaveCount(0);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
});
await test.step("clicking again restores Changes", async () => {
await pill.click();
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens the compact explorer instead of a Changes tab", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-compact-");
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.getByTestId("composer-diff-stat-pill").click();
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.getByTestId("composer-diff-stat-pill").click();
const mainPane = visibleMainPane(page);
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,80 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visibleNewTabPanel(page: Page): Locator {
return page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
}
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
}
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+E`);
const launcher = page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-new-tab-panel");
await expect(launcher).toBeVisible({ timeout: 30_000 });
return launcher;
}
test.describe("New tab keyboard launcher", () => {
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const launcher = await openSidePanelWithKeyboard(page);
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(
page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-tab-files"),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
test("a new split pane focuses its launcher and supports arrow navigation", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleNewTabPanel(page);
await expect(launcher).toBeVisible({ timeout: 30_000 });
await expectLauncherSelection(launcher, "Agent");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Terminal");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await workspace.cleanup();
}
});
});
@@ -2,9 +2,8 @@
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
// MAIN pane in the background — the explorer pane is a background surface and must
// never swallow entity tabs or lose the user's focus, and
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
// interactive in the [agent | empty main | explorer] state (the reporter hit an
// app-wide lockup here with no way to close the empty pane).
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
// interactive in the [agent | New | explorer] state.
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
@@ -53,19 +52,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
}
function emptyPaneChild(page: Page): Locator {
function newTabPaneChild(page: Page): Locator {
return page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
.first();
}
async function emptyPaneBox(page: Page) {
const paneChild = emptyPaneChild(page);
const paneChild = newTabPaneChild(page);
await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Empty pane has no bounding box");
if (!box) throw new Error("New tab pane has no bounding box");
return box;
}
@@ -97,7 +96,7 @@ async function dragChipTo(
}
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
}, testInfo) => {
const consoleErrors: string[] = [];
@@ -171,9 +170,9 @@ test.describe("explorer pane tab placement", () => {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
@@ -186,17 +185,12 @@ test.describe("explorer pane tab placement", () => {
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = emptyPaneChild(page);
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// The empty pane's + menu must open and dismiss cleanly.
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
await emptyPaneTrigger.click({ timeout: 5_000 });
await page.keyboard.press("Escape");
// A second drag must work: center-drop the agent tab back into the empty pane.
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
@@ -207,8 +201,8 @@ test.describe("explorer pane tab placement", () => {
contentType: "image/png",
});
// The agent chip must have moved into the previously empty pane and stay clickable.
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
@@ -71,7 +71,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(dockedSidebar(page)).toHaveCount(0);
await toggle.click();
@@ -108,7 +108,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
@@ -122,7 +122,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
});
} finally {
await workspace.cleanup();
+82 -4
View File
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-lockfile-",
title: "Large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "package-lock.json");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText('"package-0"');
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
} finally {
await session.cleanup();
}
});
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-plain-",
title: "Plain large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "plain.txt"),
"plain source\n".repeat(1_050_000),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "plain.txt");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText("plain source");
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
await expect(page.getByTestId("file-source-editor")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refuses a file above the display budget and keeps its tab recoverable", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-unsupported-",
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "too-large.txt");
await expect(page.getByTestId("file-source-too-large")).toContainText(
"This file is too large to display",
);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
@@ -413,10 +494,7 @@ test.describe("CodeMirror workspace file editing", () => {
.filter({ visible: true })
.first()
.click({ button: "right" });
await page
.getByTestId("workspace-tab-context-file_draft.ts-close")
.filter({ visible: true })
.click();
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
expect(closePrompt).toContain("Closing it will discard the draft.");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
finalText: string,
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
) {
await page.evaluate(() => {
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
(
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
).__fileTransitionOriginalChip = originalChip;
});
await page.evaluate((tabTestId) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => {
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
const chipIdentity = await page.evaluate((tabTestId) => {
const windowWithOriginal = window as typeof window & {
__fileTransitionOriginalChip?: Element | null;
};
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
return {
originalStillConnected: originalChip?.isConnected ?? false,
sameNode: originalChip === currentChip,
};
}, `workspace-tab-file_${targetPath}`);
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
const heldTimeline = await page.evaluate(
() =>
(
@@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
let workspace: SeededWorkspace;
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
return locator.evaluate((element) => {
let current: Element | null = element;
while (current) {
const color = getComputedStyle(current).backgroundColor;
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
current = current.parentElement;
}
return "transparent";
});
}
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "file-tree-rail-toggle-",
@@ -21,6 +33,21 @@ test.afterAll(async () => {
});
test.describe("File panel tree rail", () => {
test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
await expect(rail).toBeVisible({ timeout: 30_000 });
const content = rail.getByTestId("files-tree-rail-content");
const tree = rail.getByTestId("files-tree-rail-tree");
await expect(content).toHaveText("Choose a file");
await expect(tree).toContainText("docs");
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
await effectiveBackground(content),
);
});
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
+84 -54
View File
@@ -1,11 +1,11 @@
import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
openNewTabMenuWithShortcut,
pressDirectNewTabShortcut,
clickNewChat,
clickNewTerminal,
countTabsOfKind,
@@ -45,6 +45,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
};
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
return tabs.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
}
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
return JSON.stringify(snapshot.map(({ id }) => id));
}
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
const created = await workspace.client.createWorkspace({
@@ -66,14 +76,12 @@ test.afterAll(async () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => {
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
if (!secondWorkspaceId) {
throw new Error("Secondary workspace was not created");
}
const serverId = getServerId();
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
const switchWorkspaceRow = async (workspaceId: string) => {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -92,11 +100,12 @@ test.describe("Tab creation", () => {
for (let i = 0; i < 8; i++) {
await switchWorkspaceRow(sequence[i % sequence.length]);
await pressNewTabShortcut(page);
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
`New tab did not open after switch ${i}`,
).toBeVisible({
timeout: 5_000,
});
await page.keyboard.press("Escape");
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
}
});
@@ -104,59 +113,64 @@ test.describe("Tab creation", () => {
await gotoWorkspace(page, workspace.workspaceId);
const workspaceUrl = page.url();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
await page.keyboard.press(`${modifier}+Comma`);
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
await page.keyboard.press(`${modifier}+t`);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await page.goto(workspaceUrl);
await assertNewTabMenuTriggerVisible(page);
const countBefore = await countTabsOfKind(page, "draft");
await pressDirectNewTabShortcut(page, "a");
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
});
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await openNewTabMenuWithShortcut(page);
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
const countBefore = await newTabs.count();
await pressDirectNewTabShortcut(page, "a");
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressDirectNewTabShortcut(page, "a");
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countAfterFirst + 1);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
});
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openNewTabMenuWithShortcut(page);
const agent = page.getByRole("menuitem", { name: /^Agent/ });
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
const changes = page.getByRole("menuitem", { name: /Changes/ });
const files = page.getByRole("menuitem", { name: /Files/ });
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const agent = panel.getByRole("button", { name: /^Agent/ });
const terminal = panel.getByRole("button", { name: /^Terminal/ });
const changes = panel.getByRole("button", { name: /Changes/ });
const files = panel.getByRole("button", { name: /Files/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(agent).toBeFocused();
@@ -164,12 +178,10 @@ test.describe("Tab creation", () => {
await expect(terminal).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
"aria-expanded",
"false",
);
await page.locator("body").click({ position: { x: 1, y: 1 } });
await panel.click({ position: { x: 20, y: 20 } });
await expect(agent).toBeFocused();
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
@@ -205,8 +217,8 @@ test.describe("Tab creation", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page.getByRole("button", { name: EMPTY_PROMPT_PROFILE.name }).click();
await expectTerminalOutputContains(page, "prompt-args: 0");
} finally {
@@ -214,6 +226,20 @@ test.describe("Tab creation", () => {
}
});
test("terminal profiles are grouped with a settings action", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
await expect(panel.getByText("Terminal profiles", { exact: true })).toBeVisible();
const editProfiles = panel.getByTestId("workspace-new-tab-edit-terminal-profiles");
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
await editProfiles.click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
@@ -226,15 +252,13 @@ test.describe("Tab creation", () => {
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
const newTabButtonInScroll = await scrollArea
.getByTestId("workspace-new-tab-menu-trigger")
.count();
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
const scrollShades = await tabRow
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
.count();
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
});
});
@@ -333,28 +357,34 @@ test.describe("Terminal title propagation", () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
test("New agent tab transition has no blank intermediate tab state", async ({
page,
withWorkspace,
}) => {
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
await isolatedWorkspace.navigateTo();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
// Sample the single New → Agent replacement, not the separate action that
// creates the New tab in the first place.
const snapshots = await sampleTabsDuringTransition(page, async () => {
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
});
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1);
}
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
// When running in-suite, previous tests may have created tabs on the shared workspace,
// so we allow +2 tolerance for accumulated state and React render batching.
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
const counts = snapshots.map((snapshot) => snapshot.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
2,
);
expect(counts.every((count) => count === initialCount)).toBe(true);
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
});
@@ -0,0 +1,49 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
test("a workspace with an existing agent never presents New during startup", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Existing startup agent",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "ready",
});
try {
await page.addInitScript(() => {
Reflect.set(globalThis, "__paseoSawVisibleNewTab", false);
function recordVisibleNewTab() {
const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]');
if (panel?.getClientRects().length) {
Reflect.set(globalThis, "__paseoSawVisibleNewTab", true);
}
}
window.addEventListener("DOMContentLoaded", () => {
new MutationObserver(recordVisibleNewTab).observe(document.documentElement, {
childList: true,
subtree: true,
});
recordVisibleNewTab();
});
});
await gotoWorkspace(page, workspace.workspaceId);
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount(
0,
);
expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe(
false,
);
} finally {
await workspace.cleanup();
}
});
+48 -31
View File
@@ -18,18 +18,20 @@ function mainPane(page: Page): Locator {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function emptyState(scope: Locator): Locator {
return scope.getByTestId("workspace-pane-empty-state");
function newTabPanel(scope: Locator): Locator {
return scope.getByTestId("workspace-new-tab-panel");
}
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
return launcher
.getByRole("button")
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
}
function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first();
}
function closePaneButton(page: Page): Locator {
return page.getByRole("button", { name: "Close pane" });
}
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
const paneBox = await mainPane(page).boundingBox();
@@ -61,13 +63,20 @@ test.describe("Side panel", () => {
await test.step("revealing the Side panel seeds nothing", async () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
const launcher = newTabPanel(sidePanel(page));
await expect(launcher).toBeVisible();
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
"Changes",
"Files",
"Terminal",
"Agent",
]);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
await capture(page, testInfo, "01-side-panel-reveals-empty");
});
await test.step("Changes opens in the Side panel that offered it", async () => {
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
@@ -77,10 +86,6 @@ test.describe("Side panel", () => {
await capture(page, testInfo, "02-changes-opens-in-side-panel");
});
await test.step("a loaded pane offers no one-tap close", async () => {
await expect(closePaneButton(page)).toHaveCount(0);
});
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
await changesTab.hover();
@@ -93,7 +98,7 @@ test.describe("Side panel", () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
await expect(newTabPanel(sidePanel(page))).toBeVisible();
await capture(page, testInfo, "04-side-panel-revealed-empty");
});
} finally {
@@ -123,23 +128,30 @@ test.describe("Side panel", () => {
.first();
await expect(draftPane).toBeVisible({ timeout: 15_000 });
const emptyMain = emptyState(mainPane(page));
const emptyMain = newTabPanel(mainPane(page));
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, "06-empty-main-pane-launcher");
await emptyMain.getByRole("button", { name: "Close pane" }).click();
const newTabChip = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await newTabChip.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.click();
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
await expect(sidePanel(page)).toBeVisible();
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
await capture(page, testInfo, "07-empty-main-pane-removed");
} finally {
await workspace.cleanup();
}
});
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
try {
@@ -150,7 +162,9 @@ test.describe("Side panel", () => {
// holds that tab, so emptying Main does not look like an empty workspace and
// nothing reseeds a draft into it.
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
await newTabPanel(sidePanel(page))
.getByRole("button", { name: "Files", exact: true })
.click();
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
timeout: 30_000,
});
@@ -168,13 +182,20 @@ test.describe("Side panel", () => {
.first()
.click();
// Main is now empty and the only pane the user can see. Taking it away would
// leave nowhere to look, so the launcher appears without a Close pane.
const lastPane = emptyState(mainPane(page));
// Main now contains New and is the only pane the user can see. Closing its
// tab cannot remove the final visible pane.
const lastPane = newTabPanel(mainPane(page));
await expect(lastPane).toBeVisible({ timeout: 15_000 });
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
await expect(closePaneButton(page)).toHaveCount(0);
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
const finalNewTab = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await finalNewTab.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.click();
await expect(lastPane).toBeVisible();
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
} finally {
await workspace.cleanup();
@@ -190,18 +211,14 @@ test.describe("Side panel", () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "08-changes-in-side-panel");
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
await page
.getByTestId("workspace-new-tab-menu-changes")
.filter({ visible: true })
.first()
.click();
await mainPane(page).getByTestId("workspace-new-tab-button").click();
await page.getByTestId("workspace-new-tab-changes").filter({ visible: true }).first().click();
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 15_000,
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
beforeXterm.rows,
);
// Split the pane downward. This focuses the new empty pane, so the terminal
// Split the pane downward. This focuses the new tab pane, so the terminal
// pane is unfocused at the exact moment its container shrinks.
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
@@ -59,8 +59,8 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
// Workspaces always render a hidden explorer companion pane alongside the
// main pane, so an unscoped testid locator matches both; scope to the
// visible one.
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-menu-terminal").click();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-terminal").filter({ visible: true }).first().click();
}
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
@@ -128,7 +128,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
).toBeVisible({
timeout: 30_000,
});
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
.toBe(promptTitle);
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
await agentTab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Escape");
@@ -65,10 +65,10 @@ test.describe("Workspace pane mounting", () => {
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const copyTerminalId = page.getByTestId(
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
);
const copyTerminalId = page.getByRole("menuitem", {
name: /^Copy terminal id/,
});
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
await copyTerminalId.click();
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
}
if (draft.notes !== undefined) {
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
}
}
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
await expect(notesHint).toHaveText(
"Helps agents choose this profile when starting another agent.",
);
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
"1",
);
for (const [field, value] of Object.entries(expected)) {
if (field === "notes") {
await expect(
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
).toHaveValue(value);
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
value,
);
continue;
}
await expect(
+11 -17
View File
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`);
@@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
await pressNewTabShortcut(page);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible();
}
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
@@ -83,36 +83,30 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
/** Assert the inline plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
/** Assert the New tab button is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
/** Create a New tab and choose Agent to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> {
await createAgentTabFromMenu(page);
}
/** Open the new-tab menu and click "New terminal". */
/** Create a New tab and choose Terminal. */
export async function clickNewTerminal(page: Page): Promise<void> {
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first();
const item = page.getByTestId("workspace-new-tab-terminal").filter({ visible: true }).first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
}
@@ -137,7 +131,7 @@ export async function waitForTabWithTitle(
/** Assert the inline plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
@@ -224,7 +218,7 @@ export async function expectTabTitleFits(
}
export function terminalSurfaceLocator(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first();
}
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions {
repoPrefix: string;
title: string;
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
port?: number;
initialPrompt?: string;
model?: string;
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
const workspace = await seedWorkspace({
repoPrefix: options.repoPrefix,
repo: options.repo,
port: options.port,
});
try {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -105,7 +105,6 @@ export async function declineRelay(page: Page): Promise<void> {
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
await enableButton.click();
await expect(page.getByRole("button", { name: "Enabling...", exact: true })).toBeDisabled();
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
await expect(page.getByText("Enable relay?", { exact: true })).toHaveCount(0);
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
diffStat: { additions: number; deletions: number } | null;
labels?: string[];
}
@@ -88,13 +88,16 @@ export async function ensureSidePanel(page: Page): Promise<Locator> {
*/
async function openSidePanelView(
page: Page,
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
view: { tabTestId: string; launcherTestId: string; contentTestId: string; timeout?: number },
): Promise<void> {
const panel = await ensureSidePanel(page);
const tab = panel.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) {
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, view.menuTestId).first().click();
const launcher = panel.getByTestId("workspace-new-tab-panel");
if ((await launcher.count()) === 0) {
await panel.getByTestId("workspace-new-tab-button").click();
}
await panel.getByTestId(view.launcherTestId).click();
}
await selectWorkspaceTab(tab);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
@@ -105,7 +108,7 @@ async function openSidePanelView(
export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
menuTestId: "workspace-new-tab-menu-changes",
launcherTestId: "workspace-new-tab-changes",
contentTestId: "working-diff-panel",
});
}
@@ -113,7 +116,7 @@ export async function openChangesPanel(page: Page): Promise<void> {
export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
menuTestId: "workspace-new-tab-menu-files",
launcherTestId: "workspace-new-tab-files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -127,7 +130,7 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
}
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
menuTestId: "workspace-new-tab-menu-pull-request",
launcherTestId: "workspace-new-tab-pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
@@ -137,17 +140,17 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({
timeout: 30_000,
});
}
/** Open the `+` menu in the tab row and pick "New agent". */
/** Create a New tab from `+` and pick Agent in its launcher. */
export async function createAgentTabFromMenu(page: Page): Promise<void> {
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
const item = visibleTestId(page, "workspace-new-tab-agent").first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.5.0-beta.4",
"version": "0.5.0-beta.5",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,7 +1,6 @@
import { withUnistyles } from "react-native-unistyles";
import {
BookOpen,
Bot,
Boxes,
Brain,
Bug,
@@ -27,6 +26,7 @@ import {
Server,
Shield,
Sparkles,
Star,
Terminal,
TestTube,
Wrench,
@@ -42,7 +42,7 @@ import {
} from "./profile-appearance";
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
const ThemedDefaultIcon = withUnistyles(Bot);
const ThemedDefaultIcon = withUnistyles(Star);
/**
* `withUnistyles` has to wrap each icon once at module scope, so the registry
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { layoutStream } from "./layout";
import type { StreamStrategy } from "./strategy";
function strategyFor(platform: "web" | "native"): StreamStrategy {
const inverted = platform === "native";
const neighborStep = inverted ? 1 : -1;
const getHistoryBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? 0 : items.length - 1;
};
const getLiveHeadBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? items.length - 1 : 0;
};
return {
orderTail: (items) => (inverted ? [...items].toReversed() : items),
orderHead: (items) => (inverted ? [...items].toReversed() : items),
getNeighborIndex: (index, relation) =>
relation === "above" ? index + neighborStep : index - neighborStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep;
return items[neighborIndex];
},
getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex,
getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex,
getLatestItemIndex: getHistoryBoundaryIndex,
getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"),
} as StreamStrategy;
}
function userMessage(id: string): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: new Date("2026-08-22T10:00:00.000Z"),
};
}
function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: new Date("2026-08-22T10:00:01.000Z"),
};
}
function assistantBlock(
id: string,
blockIndex: number,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
...assistantMessage(id),
blockGroupId: "response",
blockIndex,
};
}
function timingFor(assistantId: string): Map<string, TurnTiming> {
return new Map([
[
assistantId,
{
startedAt: new Date("2026-08-22T10:00:00.000Z"),
completedAt: new Date("2026-08-22T10:00:02.000Z"),
durationMs: 2_000,
},
],
]);
}
function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) {
const strategy = strategyFor(input.platform);
const assistant = assistantMessage("assistant");
return {
assistant,
layout: layoutStream({
strategy,
isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [],
timingByAssistantId: timingFor(assistant.id),
}),
};
}
describe("turn footer spacing", () => {
it.each(["web", "native"] as const)(
"compacts the last assistant against the completed footer on %s",
(platform) => {
const { assistant, layout } = layoutFor({ platform, isTurnActive: false });
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
it.each(["web", "native"] as const)(
"keeps the same compact edge while the footer is running on %s",
(platform) => {
const { assistant, layout } = layoutFor({ platform, isTurnActive: true });
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
it.each([
["web", false],
["web", true],
["native", false],
["native", true],
] as const)(
"compacts both edges of the final split paragraph on %s when active=%s",
(platform, isTurnActive) => {
const strategy = strategyFor(platform);
const first = assistantBlock("first", 0);
const last = assistantBlock("last", 1);
const layout = layoutStream({
strategy,
isTurnActive,
history: strategy.orderTail([userMessage("user"), first]),
liveHead: strategy.orderHead([last]),
timingByAssistantId: timingFor(last.id),
});
const firstLayout = layout.history.find((item) => item.item.id === first.id);
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
},
);
});
+1 -1
View File
@@ -330,7 +330,7 @@ describe("layoutStream", () => {
});
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
});
it.each(["web", "android"] as const)(
+11 -5
View File
@@ -49,6 +49,7 @@ interface LayoutSegmentInput {
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder;
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
@@ -249,11 +250,6 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
@@ -265,6 +261,12 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
return {
item,
@@ -294,6 +296,7 @@ const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutIte
export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const hasAuxiliaryFooter = input.isTurnActive || auxiliaryTurnFooter !== null;
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
const historyBoundaryItem =
@@ -313,6 +316,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
liveHeadBoundaryItem?.kind ?? "null",
liveHeadBoundaryItem?.turnId ?? "null",
auxiliaryTurnFooter?.itemId ?? "null",
hasAuxiliaryFooter ? "footer" : "no-footer",
].join(":");
let byKey = historyLayoutCache.get(input.history);
if (!byKey) {
@@ -328,6 +332,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
@@ -347,6 +352,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
@@ -74,6 +74,18 @@ describe("getAssistantBlockSpacing", () => {
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
});
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(
getAssistantBlockSpacing({
item: a,
aboveItem: null,
belowItem: null,
hasFooterBelow: true,
}),
).toBe("compactBottom");
});
it("returns compactTop when the item above is in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
+7 -1
View File
@@ -17,12 +17,15 @@ export function getAssistantBlockSpacing(params: {
item: StreamItem;
aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined;
hasFooterBelow?: boolean;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
if (params.item.kind !== "assistant_message") {
return "default";
}
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
const compactBottom =
params.hasFooterBelow ||
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
if (compactTop && compactBottom) return "compactBoth";
if (compactTop) return "compactTop";
if (compactBottom) return "compactBottom";
@@ -44,6 +47,9 @@ export function getGapBetweenStreamItems(
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
return 0;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
+12 -6
View File
@@ -15,7 +15,10 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import { estimateStreamItemHeight } from "./web-virtualization";
import {
estimateStreamItemHeight,
shouldAdjustScrollForVirtualRowResize,
} from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
import {
@@ -370,14 +373,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
overscan: 8,
});
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
if (historyStartPrependAnchorActiveRef.current) {
return false;
}
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
return shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current,
rowStart: item.start,
scrollOffset,
remainingDistanceFromBottom: remainingDistance,
bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
});
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
@@ -1,108 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-native", () => ({
Platform: {
OS: "web",
select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
},
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? (factory as (theme: Record<string, unknown>) => unknown)({
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
colors: {
foregroundMuted: "#aaa",
palette: { amber: { 500: "#f0b429", 700: "#b7791f" } },
},
})
: factory,
},
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
withUnistyles: <T,>(component: T) => component,
}));
vi.mock("@/components/message", () => ({
AssistantTurnFooter: () => null,
LiveElapsed: () => <span data-testid="running-turn-timestamp" />,
STREAM_METADATA_FONT_SIZE: 11,
}));
vi.mock("@/components/assistant-fork-menu", () => ({
AssistantForkMenu: () => <button data-testid="running-turn-fork" type="button" />,
}));
vi.mock("@/components/synced-loader", () => ({
SyncedLoader: () => <span data-testid="running-turn-loader" />,
}));
vi.mock("@/components/retained-panel", () => ({
useRetainedPanelActive: () => true,
}));
import { TurnFooter } from "./turn-footer";
const unusedRunningTurnStrategy = null as unknown as React.ComponentProps<
typeof TurnFooter
>["strategy"];
describe("TurnFooter", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
});
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
it("places the running-turn fork between the loader and timestamp", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<TurnFooter
isRunning
inFlightTurnStartedAt={new Date("2026-08-01T10:00:00.000Z")}
host={null}
strategy={unusedRunningTurnStrategy}
supportsTimelineCursor
onForkInFlightTurn={vi.fn()}
/>,
);
});
const footer = container.querySelector('[data-testid="turn-working-indicator"]');
const controls = Array.from(footer?.querySelectorAll("[data-testid]") ?? []).map((node) =>
node.getAttribute("data-testid"),
);
expect(controls).toEqual([
"running-turn-loader",
"running-turn-fork",
"running-turn-timestamp",
]);
});
});
@@ -23,7 +23,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[6];
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -220,7 +220,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
},
turnFooterRow: {
marginTop: theme.spacing[4],
marginTop: theme.spacing[2] + 5,
},
turnFooterSlot: {
flexDirection: "row",
+15 -2
View File
@@ -260,6 +260,8 @@ export interface AgentStreamViewProps {
isAuthoritativeHistoryReady?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */
bottomOverlayControlClearance?: number;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean;
@@ -295,6 +297,10 @@ function useRetainedValue<T>(value: T, active: boolean): T {
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
function resolveBottomOverlayControlOffset(clearance: number | undefined): number {
return Math.max(16, clearance ?? 0);
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -309,6 +315,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
onOpenWorkspaceFile,
readOnly = false,
@@ -934,6 +941,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const scrollToBottomContainerStyle = useMemo(
() => [
stylesheet.scrollToBottomContainer,
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
],
[bottomOverlayControlClearance],
);
const listEmptyComponent = useMemo(
() =>
renderListEmptyComponent({
@@ -1065,7 +1079,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onJumpToPrompt={chatOutline.jumpToPrompt}
/>
{(!isNearBottom || isTimelineDetached) && (
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
@@ -1574,7 +1588,6 @@ const stylesheet = StyleSheet.create((theme) => ({
},
scrollToBottomContainer: {
position: "absolute",
bottom: 16,
left: 0,
right: 0,
alignItems: "center",
@@ -11,6 +11,7 @@ import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
shouldAdjustScrollForVirtualRowResize,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./web-virtualization";
@@ -168,6 +169,55 @@ describe("estimateStreamItemHeight", () => {
});
});
describe("virtual row resize anchoring", () => {
it("does not move the viewport when a visible row expands while detached", () => {
expect(
shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: false,
rowStart: 1200,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
}),
).toBe(false);
});
it("keeps the reading position when a row above the viewport changes size", () => {
expect(
shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: false,
rowStart: 800,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
}),
).toBe(true);
});
it("leaves history prepend and bottom following to their existing anchors", () => {
const baseInput = {
rowStart: 800,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
};
expect(
shouldAdjustScrollForVirtualRowResize({
...baseInput,
isHistoryStartPrependActive: true,
}),
).toBe(false);
expect(
shouldAdjustScrollForVirtualRowResize({
...baseInput,
isHistoryStartPrependActive: false,
remainingDistanceFromBottom: 64,
}),
).toBe(false);
});
});
describe("web virtualization test overrides", () => {
it("uses defaults unless explicit positive integer overrides are present", () => {
const globalWithOverrides = globalThis as typeof globalThis & {
@@ -10,6 +10,21 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
export function shouldAdjustScrollForVirtualRowResize(input: {
isHistoryStartPrependActive: boolean;
rowStart: number;
scrollOffset: number;
remainingDistanceFromBottom: number;
bottomThreshold: number;
}): boolean {
if (input.isHistoryStartPrependActive) {
return false;
}
return (
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
);
}
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
};
+10 -1
View File
@@ -953,9 +953,18 @@ function RootProviders({ children }: { children: ReactNode }) {
);
}
function recordUserActivity(): void {
getHostRuntimeStore().recordUserActivity();
}
function RootAppTree() {
return (
<GestureHandlerRootView style={flexStyle}>
<GestureHandlerRootView
style={flexStyle}
onTouchStart={recordUserActivity}
onTouchEnd={recordUserActivity}
onTouchCancel={recordUserActivity}
>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
newBrowser: "New browser",
splitRight: "Split pane right",
splitDown: "Split pane down",
changes: "Changes",
files: "Files",
pullRequest: "Pull request",
openPanel: (name, placement) => `Open ${name} ${placement}`,
previousTab: "Previous tab",
nextTab: "Next tab",
closeCurrentTab: "Close current tab",
renameTab: "Rename",
reloadAgent: "Reload agent",
copyResumeCommand: "Copy resume command",
copyAgentId: "Copy agent id",
copyTerminalId: "Copy terminal id",
copyFilePath: "Copy file path",
closeTabsLeft: "Close tabs left",
closeTabsRight: "Close tabs right",
closeOtherTabs: "Close other tabs",
focusPaneLeft: "Focus pane left",
focusPaneRight: "Focus pane right",
focusPaneUp: "Focus pane up",
focusPaneDown: "Focus pane down",
moveTabLeft: "Move tab left",
moveTabRight: "Move tab right",
moveTabUp: "Move tab up",
moveTabDown: "Move tab down",
closePane: "Close pane",
togglePaneMaximization: "Toggle pane maximization",
toggleFocusMode: "Toggle focus mode",
toggleSidePanel: "Toggle side panel",
},
icons: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
},
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
{ id: "tab:new-agent", rank: 0, visibility: "always" },
{ id: "git:commit", rank: 1, visibility: "always" },
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
{ id: "tab:new-browser", rank: 3, visibility: "query" },
{ id: "pane:split-right", rank: 4, visibility: "query" },
{ id: "pane:split-down", rank: 5, visibility: "query" },
expect(
contributions
.filter((item) =>
[
"tab:new-agent",
"git:commit",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
].includes(item.id),
)
.map(({ id, visibility }) => ({ id, visibility })),
).toEqual([
{ id: "tab:new-agent", visibility: "always" },
{ id: "git:commit", visibility: "always" },
{ id: "tab:new-terminal", visibility: "query" },
{ id: "tab:new-browser", visibility: "query" },
{ id: "pane:split-right", visibility: "query" },
{ id: "pane:split-down", visibility: "query" },
]);
});
it("omits browser and split actions when their existing capabilities are unavailable", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
fixture.value.capabilities = {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
});
it("dispatches every tab and pane command to the workspace scope", () => {
@@ -113,13 +164,8 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([
{ id: "workspace.agent.new", scope: "workspace" },
{ id: "workspace.terminal.new", scope: "workspace" },
{ id: "workspace.browser.new", scope: "workspace" },
{ id: "workspace.pane.split.right", scope: "workspace" },
{ id: "workspace.pane.split.down", scope: "workspace" },
]);
expect(fixture.dispatched.length).toBeGreaterThan(5);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual([
"tab:new-agent",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
]);
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
});
@@ -1,5 +1,10 @@
import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
WorkspacePanelTarget,
} from "@/keyboard/keyboard-action-dispatcher";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
newBrowser: string;
splitRight: string;
splitDown: string;
changes: string;
files: string;
pullRequest: string;
openPanel(name: string, placement: WorkspacePanelPlacement): string;
previousTab: string;
nextTab: string;
closeCurrentTab: string;
renameTab: string;
reloadAgent: string;
copyResumeCommand: string;
copyAgentId: string;
copyTerminalId: string;
copyFilePath: string;
closeTabsLeft: string;
closeTabsRight: string;
closeOtherTabs: string;
focusPaneLeft: string;
focusPaneRight: string;
focusPaneUp: string;
focusPaneDown: string;
moveTabLeft: string;
moveTabRight: string;
moveTabUp: string;
moveTabDown: string;
closePane: string;
togglePaneMaximization: string;
toggleFocusMode: string;
toggleSidePanel: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
newBrowser?: CommandCenterIcon;
splitRight?: CommandCenterIcon;
splitDown?: CommandCenterIcon;
changes?: CommandCenterIcon;
files?: CommandCenterIcon;
pullRequest?: CommandCenterIcon;
previousTab?: CommandCenterIcon;
nextTab?: CommandCenterIcon;
close?: CommandCenterIcon;
rename?: CommandCenterIcon;
reload?: CommandCenterIcon;
copy?: CommandCenterIcon;
focusPane?: CommandCenterIcon;
moveTab?: CommandCenterIcon;
maximize?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
sidePanel?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
splitRight?: ShortcutKey[][];
splitDown?: ShortcutKey[][];
archiveWorkspace?: ShortcutKey[][];
previousTab?: ShortcutKey[][];
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
capabilities: {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
}
@@ -96,7 +154,322 @@ function buildWorkspaceAction(input: {
};
}
export function buildWorkspaceCommandCenterContributions(
type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
function buildQueryAction(
source: WorkspaceCommandCenterSource,
input: QueryActionInput,
): CommandCenterContribution {
return buildWorkspaceAction({ ...input, source, visibility: "query" });
}
function buildPanelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
const panelTargets: Array<{
target: WorkspacePanelTarget;
name: string;
icon?: CommandCenterIcon;
}> = [
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
{ target: "files", name: source.labels.files, icon: source.icons.files },
{
target: "pull-request",
name: source.labels.pullRequest,
icon: source.icons.pullRequest,
},
];
for (const [index, panel] of panelTargets.entries()) {
if (panel.target !== "files" && !source.capabilities.isGit) continue;
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}`,
rank: 4 + index,
title: source.labels.openPanel(panel.name, "supporting"),
keywords: ["open", panel.target, "tab", "supporting"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement: "supporting",
},
}),
);
if (!source.capabilities.canSplitPanes) continue;
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}:${placement}`,
rank: 10 + index * 2 + placementIndex,
title: source.labels.openPanel(panel.name, placement),
keywords: ["open", panel.target, "tab", placement, "pane"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement,
},
}),
);
}
}
return contributions;
}
function buildActiveTabContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (source.activeTabCount === 0) return [];
const contributions: CommandCenterContribution[] = [
buildQueryAction(source, {
id: "tab:previous",
rank: 30,
title: source.labels.previousTab,
keywords: ["tab", "previous", "switch", "navigate"],
icon: source.icons.previousTab,
shortcutKeys: source.shortcuts.previousTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
}),
buildQueryAction(source, {
id: "tab:next",
rank: 31,
title: source.labels.nextTab,
keywords: ["tab", "next", "switch", "navigate"],
icon: source.icons.nextTab,
shortcutKeys: source.shortcuts.nextTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
}),
buildQueryAction(source, {
id: "tab:close-current",
rank: 32,
title: source.labels.closeCurrentTab,
keywords: ["tab", "close", "current"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closeCurrentTab,
action: { id: "workspace.tab.close-current", scope: "workspace" },
}),
];
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
contributions.push(
buildQueryAction(source, {
id: "tab:rename-current",
rank: 33,
title: source.labels.renameTab,
keywords: ["tab", "rename", "current"],
icon: source.icons.rename,
action: { id: "workspace.tab.rename-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-id",
rank: 34,
title:
source.activeTabKind === "agent"
? source.labels.copyAgentId
: source.labels.copyTerminalId,
keywords: ["tab", "copy", "id"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-id", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "agent") {
contributions.push(
buildQueryAction(source, {
id: "tab:reload-current",
rank: 35,
title: source.labels.reloadAgent,
keywords: ["tab", "agent", "reload"],
icon: source.icons.reload,
action: { id: "workspace.tab.reload-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-resume-command",
rank: 36,
title: source.labels.copyResumeCommand,
keywords: ["tab", "agent", "copy", "resume", "command"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "file") {
contributions.push(
buildQueryAction(source, {
id: "tab:copy-file-path",
rank: 37,
title: source.labels.copyFilePath,
keywords: ["tab", "file", "copy", "path"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
}),
);
}
if (source.activeTabIndex > 0) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-left",
rank: 38,
title: source.labels.closeTabsLeft,
keywords: ["tab", "close", "left"],
icon: source.icons.close,
action: { id: "workspace.tab.close-left", scope: "workspace" },
}),
);
}
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-right",
rank: 39,
title: source.labels.closeTabsRight,
keywords: ["tab", "close", "right"],
icon: source.icons.close,
action: { id: "workspace.tab.close-right", scope: "workspace" },
}),
);
}
if (source.activeTabCount > 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-others",
rank: 40,
title: source.labels.closeOtherTabs,
keywords: ["tab", "close", "others"],
icon: source.icons.close,
action: { id: "workspace.tab.close-others", scope: "workspace" },
}),
);
}
return contributions;
}
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
const paneActions: Array<{
id: string;
rank: number;
title: string;
keywords: string[];
action: KeyboardActionDefinition;
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
}> = [
{
id: "pane:split-right",
rank: 50,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
},
{
id: "pane:split-down",
rank: 51,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
},
{
id: "pane:focus-left",
rank: 52,
title: source.labels.focusPaneLeft,
keywords: ["pane", "focus", "left"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.left", scope: "workspace" },
},
{
id: "pane:focus-right",
rank: 53,
title: source.labels.focusPaneRight,
keywords: ["pane", "focus", "right"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.right", scope: "workspace" },
},
{
id: "pane:focus-up",
rank: 54,
title: source.labels.focusPaneUp,
keywords: ["pane", "focus", "up"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.up", scope: "workspace" },
},
{
id: "pane:focus-down",
rank: 55,
title: source.labels.focusPaneDown,
keywords: ["pane", "focus", "down"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.down", scope: "workspace" },
},
{
id: "pane:move-tab-left",
rank: 56,
title: source.labels.moveTabLeft,
keywords: ["pane", "tab", "move", "left"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
},
{
id: "pane:move-tab-right",
rank: 57,
title: source.labels.moveTabRight,
keywords: ["pane", "tab", "move", "right"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
},
{
id: "pane:move-tab-up",
rank: 58,
title: source.labels.moveTabUp,
keywords: ["pane", "tab", "move", "up"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
},
{
id: "pane:move-tab-down",
rank: 59,
title: source.labels.moveTabDown,
keywords: ["pane", "tab", "move", "down"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
},
{
id: "pane:close",
rank: 60,
title: source.labels.closePane,
keywords: ["pane", "close"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closePane,
action: { id: "workspace.pane.close", scope: "workspace" },
},
{
id: "pane:maximize-toggle",
rank: 61,
title: source.labels.togglePaneMaximization,
keywords: ["pane", "maximize", "restore", "toggle"],
icon: source.icons.maximize,
shortcutKeys: source.shortcuts.togglePaneMaximization,
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
},
{
id: "pane:focus-mode-toggle",
rank: 62,
title: source.labels.toggleFocusMode,
keywords: ["pane", "focus", "mode", "toggle"],
icon: source.icons.focusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
},
];
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
const primary = source.gitActions.primary;
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-terminal",
rank: 2,
title: source.labels.newTerminal,
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
icon: source.icons.newTerminal,
shortcutKeys: source.shortcuts.newTerminal,
action: { id: "workspace.terminal.new", scope: "workspace" },
visibility: "query",
}),
);
if (source.capabilities.canOpenBrowserTabs) {
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-browser",
rank: 3,
title: source.labels.newBrowser,
keywords: ["browser", "web", "preview"],
icon: source.icons.newBrowser,
action: { id: "workspace.browser.new", scope: "workspace" },
visibility: "query",
}),
);
}
if (source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "pane:split-right",
rank: 4,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceAction({
source,
id: "pane:split-down",
rank: 5,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
visibility: "query",
}),
);
}
return contributions;
}
export function buildWorkspaceCommandCenterContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions = [
...buildCreationContributions(source),
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "side-panel:toggle",
rank: 70,
title: source.labels.toggleSidePanel,
keywords: ["side", "panel", "toggle", "show", "hide"],
icon: source.icons.sidePanel,
shortcutKeys: source.shortcuts.toggleSidePanel,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
return contributions;
}
@@ -1,18 +1,41 @@
import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
import {
ArrowDownToLine,
ArrowLeft,
ArrowRight,
Columns2,
Copy,
Files,
Focus,
GitCompareArrows,
GitPullRequest,
Globe,
Maximize2,
Move,
PanelRight,
Pencil,
RotateCw,
Rows2,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import {
resolveShortcutKeysForAction,
type ShortcutOverrides,
} from "@/keyboard/keyboard-shortcuts";
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon";
@@ -22,6 +45,7 @@ import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
const WORKSPACE_COMMAND_CENTER_ICONS = {
newAgent: getCommandCenterIcon(SquarePen),
@@ -29,8 +53,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
newBrowser: getCommandCenterIcon(Globe),
splitRight: getCommandCenterIcon(Columns2),
splitDown: getCommandCenterIcon(Rows2),
changes: getCommandCenterIcon(GitCompareArrows),
files: getCommandCenterIcon(Files),
pullRequest: getCommandCenterIcon(GitPullRequest),
previousTab: getCommandCenterIcon(ArrowLeft),
nextTab: getCommandCenterIcon(ArrowRight),
close: getCommandCenterIcon(X),
rename: getCommandCenterIcon(Pencil),
reload: getCommandCenterIcon(RotateCw),
copy: getCommandCenterIcon(Copy),
focusPane: getCommandCenterIcon(Focus),
moveTab: getCommandCenterIcon(Move),
maximize: getCommandCenterIcon(Maximize2),
focusMode: getCommandCenterIcon(ArrowDownToLine),
sidePanel: getCommandCenterIcon(PanelRight),
};
const OPEN_PANEL_LABEL_KEYS = {
supporting: "shell.commandCenter.open",
"side-panel": "shell.commandCenter.openInSidePanel",
"focused-pane": "shell.commandCenter.openInFocusedPane",
} as const;
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
if (!element) return undefined;
function StaticIcon() {
@@ -41,17 +85,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
return {
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
newTerminal:
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
splitRight:
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
splitDown:
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
};
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
}
export function useWorkspaceCommandCenterActions(): void {
@@ -60,10 +94,22 @@ export function useWorkspaceCommandCenterActions(): void {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const workspaceKey =
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
);
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
const focusedTabs = layout
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
: [];
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions } = useGitActions({
const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "",
cwd: cwd ?? "",
icons: GIT_ACTION_ICONS,
@@ -81,6 +127,34 @@ export function useWorkspaceCommandCenterActions(): void {
newBrowser: t("workspace.tabs.actions.newBrowser"),
splitRight: t("workspace.tabs.actions.splitRight"),
splitDown: t("workspace.tabs.actions.splitDown"),
changes: t("workspace.tabs.actions.changes"),
files: t("workspace.tabs.actions.files"),
pullRequest: t("workspace.tabs.actions.pullRequest"),
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
previousTab: t("settings.shortcuts.help.previousTab"),
nextTab: t("settings.shortcuts.help.nextTab"),
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
renameTab: t("workspace.tabs.menu.rename"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
closeTabsRight: t("workspace.tabs.menu.closeRight"),
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
closePane: t("settings.shortcuts.help.closePane"),
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -90,14 +164,29 @@ export function useWorkspaceCommandCenterActions(): void {
capabilities: {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
}),
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
[
activeTabIndex,
activeTabKind,
focusedTabs.length,
gitActions,
isCompact,
isGit,
keyboardActionDispatcher,
overrides,
runGitAction,
t,
],
);
useCommandCenterActions({
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
describe("resolveWorkspaceCommandCenterShortcuts", () => {
it("assigns the New agent command its own shortcut", () => {
expect(
resolveWorkspaceCommandCenterShortcuts({
overrides: {},
platform: { isMac: true, isDesktop: true },
}).newAgent,
).toEqual([["mod", "shift", "A"]]);
});
});
@@ -0,0 +1,39 @@
import {
resolveShortcutKeysForAction,
type ShortcutOverrides,
} from "@/keyboard/keyboard-shortcuts";
import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions";
interface ResolveWorkspaceCommandCenterShortcutsInput {
overrides: ShortcutOverrides;
platform: { isMac: boolean; isDesktop: boolean };
}
export function resolveWorkspaceCommandCenterShortcuts({
overrides,
platform,
}: ResolveWorkspaceCommandCenterShortcutsInput): WorkspaceCommandCenterShortcuts {
return {
newAgent:
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, platform) ?? undefined,
newTerminal:
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
splitRight:
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
splitDown:
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
previousTab:
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
closeCurrentTab:
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
togglePaneMaximization:
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
toggleSidePanel:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
};
}
@@ -1662,7 +1662,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surfaceSidebar,
},
desktopSplit: {
flex: 1,
+14 -2
View File
@@ -13,7 +13,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
@@ -1455,7 +1455,19 @@ function ModelBrowserContent({
return scrolling === "independent" ? (
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
) : (
allProvidersContent
<BottomSheetScrollView
style={styles.virtualizedModelList}
contentContainerStyle={[
styles.virtualizedModelListContent,
styles.virtualizedProviderListContent,
]}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
testID="compact-provider-list"
>
{allProvidersContent}
</BottomSheetScrollView>
);
}
+26 -88
View File
@@ -69,7 +69,6 @@ import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -110,10 +109,7 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
onCreateNewTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
@@ -129,14 +125,8 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onClosePane: (paneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean;
onExitFocusMode: () => void;
}
@@ -182,7 +172,6 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
paneIsolated: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
@@ -402,19 +391,14 @@ export function SplitContainer({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
focusModeEnabled,
onExitFocusMode,
}: SplitContainerProps) {
@@ -502,12 +486,6 @@ export function SplitContainer({
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
// Isolating a pane (focus mode, maximize) hides its siblings without removing
// them, so the one pane on screen is not really the last one and closing it is not
// what anyone means. That is all this container knows; whether the layout itself
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
if (!data) {
@@ -687,19 +665,13 @@ export function SplitContainer({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
@@ -898,19 +870,13 @@ function SplitNodeView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
paneIsolated,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
@@ -979,18 +945,12 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
@@ -1033,19 +993,13 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
@@ -1095,18 +1049,12 @@ function SplitPaneView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty,
paneIsolated,
onClosePane,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
@@ -1218,7 +1166,6 @@ function SplitPaneView({
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View
@@ -1247,10 +1194,7 @@ function SplitPaneView({
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
externalDndContext
activeDragTabId={activeDragTabId}
@@ -1266,31 +1210,25 @@ function SplitPaneView({
</WindowChromeSafeArea>
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
{mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
onClosePane: handleClosePane,
}) ?? null)}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
@@ -86,23 +86,28 @@ describe("control geometry", () => {
expect(geometry.formTextInputMd.lineHeight).toBe(20);
});
it("derives field padding from line height without changing the control height", () => {
it("derives field padding from content and border without changing the control height", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
geometry.fieldControlSm.minHeight,
);
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
expect(geometry.fieldControlMd.minHeight).toBe(44);
expect(geometry.fieldControlMd.paddingVertical).toBe(12);
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
geometry.fieldControlMd.minHeight,
);
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight);
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
expect(geometry.formTextInputMd.paddingVertical).toBe(12);
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
});
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
@@ -78,8 +78,12 @@ function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
return (controlHeight - lineHeight) / 2;
function fieldVerticalPadding(
controlHeight: number,
lineHeight: number,
borderWidth: number,
): number {
return (controlHeight - lineHeight - borderWidth * 2) / 2;
}
export function getControlInteractionPhase(
@@ -111,18 +115,27 @@ export function resolveControlInteractionStyles(
}
export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.compact, fieldTextSmLineHeight),
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.compact,
fieldTextSmLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlMd = {
minHeight: CONTROL_HEIGHTS.field,
paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.field, fieldTextMdLineHeight),
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.field,
fieldTextMdLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.lg,
};
const fieldTextSm = {
@@ -182,7 +195,7 @@ export function createControlGeometry(theme: Theme) {
fieldTextSm,
fieldTextMd,
controlRest: {
borderWidth: theme.borderWidth[1],
borderWidth: controlBorderWidth,
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
@@ -45,7 +45,7 @@ export function Field({ label, children, hint, error, testID }: FieldProps) {
}
if (hint) {
return (
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
<Text style={styles.hintText} testID={hintTestID}>
{hint}
</Text>
);
+1 -1
View File
@@ -70,7 +70,7 @@ const styles = StyleSheet.create((theme) => ({
badgeBackground: {
...StyleSheet.absoluteFillObject,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface4,
backgroundColor: theme.colors.surface3,
opacity: theme.opacity[50],
},
sequence: {
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { seedSessionWorkspaces } from "@/test/seed-session";
import { useWorkspaceHasDiffStat } from "./workspace-diff-stat";
const SERVER_ID = "diff-stat-pill";
const WORKSPACE_ID = "workspace";
const workspace: WorkspaceDescriptor = {
id: WORKSPACE_ID,
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
function setDiffStat(diffStat: WorkspaceDescriptor["diffStat"]): void {
useSessionStore.getState().setWorkspaces(SERVER_ID, (workspaces) => {
const next = new Map(workspaces);
next.set(WORKSPACE_ID, { ...workspace, diffStat });
return next;
});
}
describe("useWorkspaceHasDiffStat", () => {
beforeEach(() => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
seedSessionWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, workspace]]));
});
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
});
it("does not re-render its owner when only the visible counts change", () => {
let renderCount = 0;
const { result } = renderHook(() => {
renderCount += 1;
return useWorkspaceHasDiffStat(SERVER_ID, WORKSPACE_ID);
});
expect(result.current).toBe(false);
expect(renderCount).toBe(1);
act(() => setDiffStat({ additions: 2, deletions: 0 }));
expect(result.current).toBe(true);
expect(renderCount).toBe(2);
act(() => setDiffStat({ additions: 5, deletions: 3 }));
expect(result.current).toBe(true);
expect(renderCount).toBe(2);
act(() => setDiffStat(null));
expect(result.current).toBe(false);
expect(renderCount).toBe(3);
});
});
@@ -0,0 +1,59 @@
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable } from "react-native";
import { useTranslation } from "react-i18next";
import { DiffStat } from "@/components/diff-stat";
import { composerPillStyles } from "@/composer/pill-styles";
import { useVisibleWorkspaceDiffStat } from "@/composer/workspace-diff-stat";
interface ComposerDiffStatPillProps {
additions: number;
deletions: number;
onPress: () => void;
}
export function ComposerDiffStatPill({ additions, deletions, onPress }: ComposerDiffStatPillProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const bodyStyle = useMemo(
() => [composerPillStyles.body, isHovered && composerPillStyles.bodyActive],
[isHovered],
);
return (
<Pressable
testID="composer-diff-stat-pill"
accessibilityRole="button"
accessibilityLabel={t("workspace.git.diff.openChangesTab")}
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={bodyStyle}
>
<DiffStat additions={additions} deletions={deletions} />
</Pressable>
);
}
export const WorkspaceDiffStatPill = memo(function WorkspaceDiffStatPill({
serverId,
workspaceId,
onPress,
}: {
serverId: string;
workspaceId: string;
onPress: () => void;
}): ReactElement | null {
const diffStat = useVisibleWorkspaceDiffStat(serverId, workspaceId);
if (!diffStat) {
return null;
}
return (
<ComposerDiffStatPill
additions={diffStat.additions}
deletions={diffStat.deletions}
onPress={onPress}
/>
);
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style";
describe("composer input height style", () => {
it("applies the measured height alongside its bounds", () => {
expect(
resolveComposerInputHeightStyle({
inputHeight: 96,
minInputHeight: 30,
maxInputHeight: 240,
applyMeasuredHeight: true,
}),
).toEqual({
height: 96,
minHeight: 30,
maxHeight: 240,
});
});
it("lets a native multiline input expose its intrinsic content height", () => {
expect(
resolveComposerInputHeightStyle({
inputHeight: 96,
minInputHeight: 30,
maxInputHeight: 240,
applyMeasuredHeight: false,
}),
).toEqual({
minHeight: 30,
maxHeight: 240,
});
});
it("keeps native measurement unconstrained until the composer reaches its cap", () => {
expect(shouldScrollComposerInput({ inputHeight: 30, maxInputHeight: 240 })).toBe(false);
expect(shouldScrollComposerInput({ inputHeight: 240, maxInputHeight: 240 })).toBe(true);
});
});
@@ -0,0 +1,24 @@
interface ComposerInputHeightStyleInput {
inputHeight: number;
minInputHeight: number;
maxInputHeight: number;
applyMeasuredHeight: boolean;
}
export function resolveComposerInputHeightStyle(input: ComposerInputHeightStyleInput) {
// Native must remain intrinsically sized below the cap so
// onContentSizeChange can observe growth. Web gets its height from the DOM
// mirror and therefore needs that measurement applied explicitly.
return {
...(input.applyMeasuredHeight ? { height: input.inputHeight } : {}),
minHeight: input.minInputHeight,
maxHeight: input.maxInputHeight,
};
}
export function shouldScrollComposerInput(input: {
inputHeight: number;
maxInputHeight: number;
}): boolean {
return input.inputHeight >= input.maxInputHeight;
}
+8 -16
View File
@@ -56,6 +56,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
@@ -990,20 +991,6 @@ function resolveMaxInputHeight(windowHeight: number): number {
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
}
function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) {
if (isWeb) {
return {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
};
}
return {
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
};
}
function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
}
@@ -1764,7 +1751,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
() => [
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
computeTextInputHeightStyle(inputHeight, maxInputHeight),
resolveComposerInputHeightStyle({
inputHeight,
minInputHeight: MIN_INPUT_HEIGHT,
maxInputHeight,
applyMeasuredHeight: isWeb,
}),
],
[inputHeight, maxInputHeight, mode.isMonospace],
);
@@ -1838,7 +1830,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
scrollEnabled={shouldScrollComposerInput({ inputHeight, maxInputHeight })}
autoFocus={false}
onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
+6
View File
@@ -12,6 +12,12 @@ export function resolveComposerPillClearance(isCompact: boolean): number {
}
export function resolveComposerTrackTailClearance(isCompact: boolean): number {
const composerClearance = resolveComposerPillClearance(isCompact);
const transcriptClearance = isCompact ? SPACING[6] : 20;
return transcriptClearance + COMPOSER_PILL_MIN_HEIGHT + composerClearance;
}
export function resolveComposerTrackControlClearance(isCompact: boolean): number {
const clearance = resolveComposerPillClearance(isCompact);
return clearance + COMPOSER_PILL_MIN_HEIGHT + clearance;
}
-131
View File
@@ -1,131 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-native", () => ({
Platform: {
OS: "web",
select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
},
Pressable: "button",
Text: "span",
// The status ring reaches for plain React Native styles rather than Unistyles ones — see the
// note on `rotatorStyles` in status-ring/frame.tsx.
StyleSheet: { create: <T,>(styles: T): T => styles },
View: ({
children,
style,
onLayout,
}: {
children?: React.ReactNode;
style?: unknown;
onLayout?: unknown;
}) => (
<div data-style={JSON.stringify(style)} data-has-layout-handler={Boolean(onLayout)}>
{children}
</div>
),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: (theme: Record<string, unknown>) => unknown) =>
factory({
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
borderRadius: { md: 6, lg: 8, xl: 12, "2xl": 16, full: 9999 },
borderWidth: { 1: 1 },
fontSize: { sm: 12 },
colors: {
statusDotWarning: "#1",
statusDotDanger: "#2",
statusDotRunning: "#3",
statusDotSuccess: "#4",
border: "#5",
surface2: "#6",
borderAccent: "#7",
surface1: "#8",
foregroundMuted: "#9",
foreground: "#a",
},
}),
},
}));
vi.mock("@/components/ui/menu", () => ({
MenuRoot: ({ children }: { children?: React.ReactNode }) => children,
MenuSurface: ({ children }: { children?: React.ReactNode }) => children,
MenuTrigger: ({ children }: { children?: React.ReactNode }) => children,
useMenuContext: () => ({ open: false }),
}));
import { ComposerTrackBar } from "./tracks";
import {
COMPOSER_PILL_CLEARANCE,
composerPillStyles,
resolveComposerPillClearance,
resolveComposerTrackTailClearance,
} from "./pill-styles";
describe("ComposerTrackBar", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
});
afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
root = null;
container = null;
vi.unstubAllGlobals();
});
it("floats over content without painting a background band", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(<ComposerTrackBar>track</ComposerTrackBar>));
const style = JSON.parse(container.firstElementChild?.getAttribute("data-style") ?? "{}");
expect(style).toMatchObject({
position: "absolute",
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 16,
paddingBottom: { xs: 8, md: 10 },
});
expect(style).not.toHaveProperty("backgroundColor");
expect(container.firstElementChild?.getAttribute("data-has-layout-handler")).toBe("false");
const trackStyle = JSON.parse(
container.firstElementChild?.firstElementChild?.getAttribute("data-style") ?? "{}",
);
expect(trackStyle).toMatchObject({ flexDirection: "row" });
expect(trackStyle).not.toHaveProperty("flexWrap");
});
it("matches the composer's corner tangent without radius clamping", () => {
expect(composerPillStyles.body).toMatchObject({
minHeight: 32,
paddingHorizontal: 12,
borderRadius: 16,
});
expect(composerPillStyles.label).toMatchObject({ fontSize: 12 });
expect(resolveComposerPillClearance(true)).toBe(8);
expect(resolveComposerPillClearance(false)).toBe(10);
expect(resolveComposerTrackTailClearance(true)).toBe(48);
expect(resolveComposerTrackTailClearance(false)).toBe(52);
expect(COMPOSER_PILL_CLEARANCE).toEqual({ compact: 8, wide: 10 });
});
});
@@ -0,0 +1,24 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
type DiffStat = NonNullable<WorkspaceDescriptor["diffStat"]>;
export function useVisibleWorkspaceDiffStat(
serverId: string,
workspaceId: string,
): DiffStat | null {
const diffStat = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.diffStat);
return hasDiffStatChanges(diffStat) ? diffStat : null;
}
export function useWorkspaceHasDiffStat(serverId: string, workspaceId: string): boolean {
return (
useWorkspaceFields(serverId, workspaceId, (workspace) =>
hasDiffStatChanges(workspace.diffStat),
) ?? false
);
}
function hasDiffStatChanges(diffStat: WorkspaceDescriptor["diffStat"]): diffStat is DiffStat {
return Boolean(diffStat && (diffStat.additions > 0 || diffStat.deletions > 0));
}
@@ -408,7 +408,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
// Client activity tracking (heartbeat, push token registration)
useClientActivity({ client, focusedAgentId, focusedTerminalId, onAppResumed: handleAppResumed });
useClientActivity({
client,
focusedAgentId,
focusedTerminalId,
onUserActivity: () => getHostRuntimeStore().recordUserActivity(),
onAppResumed: handleAppResumed,
});
useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]);
const notifyAgentAttention = useCallback(
+8 -1
View File
@@ -1,6 +1,8 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { LiveFileModel, type LiveFileSession } from "./model";
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
import { isWeb } from "@/constants/platform";
export function useLiveFile(input: {
client: DaemonClient | null;
@@ -18,7 +20,12 @@ export function useLiveFile(input: {
return client.subscribeFile(target, onVersion);
},
read(target) {
return client.readFile(target.cwd, target.path);
return client.readFile(
target.cwd,
target.path,
undefined,
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
);
},
};
}, [input.client]);
+60 -179
View File
@@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
@@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface CodeLineProps {
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
}
interface FilePreviewBodyProps {
preview: ExplorerFile | null;
mode?: "preview" | "source";
@@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
interface FileLineSelection {
lineStart: number;
lineEnd: number;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function clampLineSelection(input: {
lineStart?: number;
lineEnd?: number;
lineCount: number;
}): FileLineSelection | null {
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
return null;
}
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
const rawLineEnd =
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
function ReadonlySource({
preview,
filename,
location,
navigationRevision,
}: {
preview: ExplorerFile;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<View style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken key={key} token={token} />
))}
</Text>
<FileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
function TooLargeSource({ size }: { size?: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
</View>
);
});
interface CodeLineTokenProps {
token: HighlightToken;
}
function CodeLineToken({ token }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: "row",
},
highlightedLine: {
backgroundColor: theme.colors.accentBorder,
},
gutter: {
alignItems: "flex-end",
paddingRight: theme.spacing[3],
flexShrink: 0,
},
gutterText: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
opacity: 0.4,
userSelect: "none",
},
lineText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
flex: 1,
},
}));
function FilePreviewBody({
preview,
mode,
isLoading,
isMobile,
isMobile: _isMobile,
location,
navigationRevision,
imagePreviewUri,
}: FilePreviewBodyProps) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const filePath = location.path;
// A line target means the caller wants to land on that line, so fall back to
@@ -197,48 +146,11 @@ function FilePreviewBody({
const previewScrollRef = useRef<RNScrollView>(null);
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text" || renderKind) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
}, [renderKind, preview, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri],
);
useEffect(() => {
if (!lineSelection) {
return;
}
const timeout = setTimeout(() => {
previewScrollRef.current?.scrollTo({
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
animated: false,
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection, navigationRevision]);
if (isLoading && !preview) {
return (
<View style={styles.centerState} testID="file-preview-loading">
@@ -280,51 +192,13 @@ function FilePreviewBody({
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const keyedLines = lines.map((tokens, index) => ({
key: `line-${index}`,
tokens,
lineNumber: index + 1,
}));
const codeLines = (
<View dataSet={CODE_SURFACE_DATASET}>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
highlighted={
Boolean(lineSelection) &&
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/>
))}
</View>
);
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
showsVerticalScrollIndicator
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
</View>
<ReadonlySource
preview={preview}
filename={filePath}
location={location}
navigationRevision={navigationRevision}
/>
);
}
@@ -557,6 +431,13 @@ function FilePanePresentation({
}
if (errorMessage) {
if (errorMessage === "File is too large to display") {
return (
<View style={styles.container} testID="workspace-file-pane">
<TooLargeSource />
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { SOURCE_PRESENTATION_BUDGETS, selectSourcePresentation } from "./presentation";
describe("selectSourcePresentation", () => {
for (const platform of ["web", "native"] as const) {
const budgets = SOURCE_PRESENTATION_BUDGETS[platform];
it(`keeps ${platform} threshold edges predictable`, () => {
expect(selectSourcePresentation({ platform, size: budgets.highlighted })).toBe("highlighted");
expect(selectSourcePresentation({ platform, size: budgets.highlighted + 1 })).toBe("plain");
expect(selectSourcePresentation({ platform, size: budgets.plain })).toBe("plain");
expect(selectSourcePresentation({ platform, size: budgets.plain + 1 })).toBe("unsupported");
});
}
});
@@ -0,0 +1,17 @@
export const SOURCE_PRESENTATION_BUDGETS = {
web: { highlighted: 10 * 1024 * 1024, plain: 50 * 1024 * 1024 },
native: { highlighted: 1024 * 1024, plain: 10 * 1024 * 1024 },
} as const;
export type SourcePresentation = "highlighted" | "plain" | "unsupported";
export type SourcePlatform = keyof typeof SOURCE_PRESENTATION_BUDGETS;
export function selectSourcePresentation(input: {
size: number;
platform: SourcePlatform;
}): SourcePresentation {
const budgets = SOURCE_PRESENTATION_BUDGETS[input.platform];
if (input.size <= budgets.highlighted) return "highlighted";
if (input.size <= budgets.plain) return "plain";
return "unsupported";
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { selectSourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
interface SourceLine {
number: number;
tokens: HighlightToken[];
}
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "native" });
if (presentation === "unsupported") {
return (
<View style={styles.unsupported} testID="file-source-too-large">
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
</View>
);
}
return (
<VirtualizedSource
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
/>
);
}
function VirtualizedSource({
content,
filename,
location,
navigationRevision,
presentation,
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const listRef = useRef<FlatList<SourceLine>>(null);
const lines = useMemo(() => {
if (presentation === "highlighted")
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
useEffect(() => {
if (!location.lineStart) return;
listRef.current?.scrollToIndex({
index: Math.min(location.lineStart - 1, lines.length - 1),
animated: false,
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
}
function sourceLineKey(line: SourceLine): string {
return String(line.number);
}
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
const styles = StyleSheet.create((theme) => ({
unsupported: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
gutter: {
width: 56,
paddingRight: theme.spacing[3],
textAlign: "right",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
text: {
flex: 1,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
},
}));
@@ -0,0 +1 @@
export { FileSourceView } from "./view.native";
@@ -0,0 +1,131 @@
import { useEffect, useRef } from "react";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
const languageCompartment = new Compartment();
const themeCompartment = new Compartment();
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
theme,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "web" });
if (presentation === "unsupported") {
return (
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
{tooLargeMessage}
</div>
);
}
return (
<ReadonlyCodeMirror
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
theme={theme}
/>
);
}
function ReadonlyCodeMirror({
content,
filename,
location,
navigationRevision,
presentation,
theme,
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const initial = useRef({ content, filename, presentation, theme });
useEffect(() => {
if (!hostRef.current) return;
const values = initial.current;
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: values.content,
extensions: [
EditorState.readOnly.of(true),
EditorView.editable.of(false),
languageCompartment.of(
languageFor({ filename: values.filename, presentation: values.presentation }),
),
themeCompartment.of(editorTheme(values.theme)),
],
}),
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view || view.state.doc.toString() === content) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } });
}, [content]);
useEffect(() => {
viewRef.current?.dispatch({
effects: [
languageCompartment.reconfigure(languageFor({ filename, presentation })),
themeCompartment.reconfigure(editorTheme(theme)),
],
});
}, [filename, presentation, theme]);
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const line = Math.min(location.lineStart, view.state.doc.lines);
const from = view.state.doc.line(line).from;
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
}, [location.lineStart, navigationRevision]);
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
}
function languageFor(input: {
filename: string;
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
return input.presentation === "highlighted"
? (getLanguageForFile(input.filename)?.extension ?? [])
: [];
}
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
const UNSUPPORTED_STYLE = {
alignItems: "center",
display: "flex",
flex: 1,
justifyContent: "center",
} as const;
@@ -495,7 +495,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
schedulePaint();
return;
}
if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) {
if (
event.pointerType === "touch" &&
drag &&
!moved &&
hit?.kind === "cell" &&
hit.target &&
reviewActions
) {
selectionRef.current = null;
reviewActions.onStartComment(hit.target);
schedulePaint();
@@ -68,12 +68,26 @@ function buildTracker(
initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null,
initialAppVisible: overrides.initialAppVisible ?? true,
now: clock.now,
onUserActivity: overrides.onUserActivity ?? (() => undefined),
onAppResumed: overrides.onAppResumed,
});
return { tracker, client, clock };
}
describe("client activity tracker", () => {
it("notifies local consumers about user activity", () => {
let notifications = 0;
const { tracker } = buildTracker({
onUserActivity: () => {
notifications += 1;
},
});
tracker.recordUserActivity();
expect(notifications).toBe(1);
});
it("includes the latest user-activity time in the next heartbeat", () => {
const { tracker, client, clock } = buildTracker();
@@ -23,6 +23,7 @@ export interface ClientActivityTrackerInput {
initialFocusedTerminalId: string | null;
initialAppVisible: boolean;
now: () => number;
onUserActivity: () => void;
onAppResumed?: (awayMs: number) => void;
}
@@ -39,7 +40,7 @@ export interface ClientActivityTracker {
export function createClientActivityTracker(
input: ClientActivityTrackerInput,
): ClientActivityTracker {
const { client, deviceType, now, onAppResumed } = input;
const { client, deviceType, now, onUserActivity, onAppResumed } = input;
let lastActivityAtMs = now();
let appVisible = input.initialAppVisible;
let appVisibilityChangedAtMs = now();
@@ -62,6 +63,7 @@ export function createClientActivityTracker(
function recordUserActivity(): void {
lastActivityAtMs = now();
onUserActivity();
}
function maybeSendImmediateHeartbeat(): void {
@@ -15,6 +15,7 @@ interface ClientActivityOptions {
client: DaemonClient;
focusedAgentId: string | null;
focusedTerminalId: string | null;
onUserActivity: () => void;
onAppResumed?: (awayMs: number) => void;
}
@@ -28,6 +29,7 @@ export function useClientActivity({
client,
focusedAgentId,
focusedTerminalId,
onUserActivity,
onAppResumed,
}: ClientActivityOptions): void {
const onAppResumedRef = useRef(onAppResumed);
@@ -42,6 +44,7 @@ export function useClientActivity({
initialFocusedTerminalId: focusedTerminalId,
initialAppVisible: AppState.currentState === "active",
now: () => Date.now(),
onUserActivity,
onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs),
});
}
+8 -3
View File
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
workspaces: "مساحات العمل",
agents: "الوكلاء",
newAgent: "وكيل جديد",
open: "فتح {{name}}",
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
openInFocusedPane: "فتح {{name}} في الجزء النشط",
addProject: "إضافة مشروع",
home: "بيت",
groupByProject: "تجميع حسب المشروع",
@@ -602,7 +605,7 @@ export const ar: TranslationResources = {
files: "الملفات",
pullRequest: "طلب السحب",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "افتح اللوحة الجانبية",
@@ -1700,6 +1703,7 @@ export const ar: TranslationResources = {
loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
failedToLoad: "فشل تحميل الملف",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
@@ -1728,6 +1732,7 @@ export const ar: TranslationResources = {
label: "الملفات",
subtitle: "ملفات مساحة العمل",
tooltip: "تصفح ملفات مساحة العمل",
chooseFile: "اختر ملفًا",
},
pullRequest: {
label: "طلب السحب",
@@ -2342,9 +2347,9 @@ export const ar: TranslationResources = {
featuresLabel: "السمات",
featureCount: "{{count}} سمة",
featureCountOne: "{{count}} سمة",
notesLabel: "ملاحظات للوكلاء",
notesLabel: "متى يُستخدم",
notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.",
notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.',
notesHint: "يساعد الوكلاء على اختيار ملف التعريف هذا عند بدء وكيل آخر.",
save: "حفظ",
saving: "جارٍ الحفظ...",
remove: "إزالة",
+8 -4
View File
@@ -61,6 +61,9 @@ export const en = {
workspaces: "Workspaces",
agents: "Agents",
newAgent: "New agent",
open: "Open {{name}}",
openInSidePanel: "Open {{name}} in Side panel",
openInFocusedPane: "Open {{name}} in focused pane",
addProject: "Add project",
home: "Home",
groupByProject: "Group by project",
@@ -600,7 +603,7 @@ export const en = {
files: "Files",
pullRequest: "Pull request",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "Open side panel",
@@ -1710,6 +1713,7 @@ export const en = {
loading: "Loading file...",
noPreview: "No preview available",
binaryPreviewUnavailable: "Binary preview unavailable",
tooLargeToDisplay: "This file is too large to display",
failedToLoad: "Failed to load file",
failedToLoadPreview: "Failed to load file preview",
editor: {
@@ -1738,6 +1742,7 @@ export const en = {
label: "Files",
subtitle: "Workspace files",
tooltip: "Browse workspace files",
chooseFile: "Choose a file",
},
pullRequest: {
label: "Pull request",
@@ -2409,10 +2414,9 @@ export const en = {
featuresLabel: "Features",
featureCount: "{{count}} features",
featureCountOne: "{{count}} feature",
notesLabel: "Notes for agents",
notesLabel: "When to use",
notesPlaceholder: "Use for UI work — components, layout and design tokens.",
notesHint:
"Returned by the list_profiles MCP tool. Write it as an instruction to another agent.",
notesHint: "Helps agents choose this profile when starting another agent.",
save: "Save",
saving: "Saving...",
remove: "Remove",
+8 -4
View File
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
workspaces: "Espacios de trabajo",
agents: "Agentes",
newAgent: "Nuevo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} en el panel lateral",
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
addProject: "Agregar proyecto",
home: "Hogar",
groupByProject: "Agrupar por proyecto",
@@ -607,7 +610,7 @@ export const es: TranslationResources = {
files: "Archivos",
pullRequest: "Solicitud de extracción",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "Abrir panel lateral",
@@ -1746,6 +1749,7 @@ export const es: TranslationResources = {
loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible",
binaryPreviewUnavailable: "Vista previa binaria no disponible",
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
failedToLoad: "No se pudo cargar el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
@@ -1774,6 +1778,7 @@ export const es: TranslationResources = {
label: "Archivos",
subtitle: "Archivos del espacio de trabajo",
tooltip: "Explorar archivos del espacio de trabajo",
chooseFile: "Elige un archivo",
},
pullRequest: {
label: "Solicitud de extracción",
@@ -2396,10 +2401,9 @@ export const es: TranslationResources = {
featuresLabel: "Características",
featureCount: "{{count}} características",
featureCountOne: "{{count}} característica",
notesLabel: "Notas para los agentes",
notesLabel: "Cuándo usarlo",
notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.",
notesHint:
'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.',
notesHint: "Ayuda a los agentes a elegir este perfil al iniciar otro agente.",
save: "Guardar",
saving: "Guardando...",
remove: "Eliminar",
+8 -4
View File
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
workspaces: "Espaces de travail",
agents: "Agents",
newAgent: "Nouvel agent",
open: "Ouvrir {{name}}",
openInSidePanel: "Ouvrir {{name}} dans le panneau latéral",
openInFocusedPane: "Ouvrir {{name}} dans le volet actif",
addProject: "Ajouter un projet",
home: "Maison",
groupByProject: "Grouper par projet",
@@ -607,7 +610,7 @@ export const fr: TranslationResources = {
files: "Fichiers",
pullRequest: "Demande de fusion",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "Ouvrir le panneau latéral",
@@ -1750,6 +1753,7 @@ export const fr: TranslationResources = {
loading: "Chargement du fichier...",
noPreview: "Aucun aperçu disponible",
binaryPreviewUnavailable: "Aperçu binaire indisponible",
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
failedToLoad: "Échec du chargement du fichier",
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
editor: {
@@ -1778,6 +1782,7 @@ export const fr: TranslationResources = {
label: "Fichiers",
subtitle: "Fichiers de l’espace de travail",
tooltip: "Parcourir les fichiers de l’espace de travail",
chooseFile: "Choisissez un fichier",
},
pullRequest: {
label: "Demande de fusion",
@@ -2403,11 +2408,10 @@ export const fr: TranslationResources = {
featuresLabel: "Caractéristiques",
featureCount: "{{count}} caractéristiques",
featureCountOne: "{{count}} caractéristique",
notesLabel: "Notes pour les agents",
notesLabel: "Quand l’utiliser",
notesPlaceholder:
"À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.",
notesHint:
"Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.",
notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.",
save: "Enregistrer",
saving: "Enregistrement...",
remove: "Supprimer",
+8 -4
View File
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
workspaces: "ワークスペース",
agents: "エージェント",
newAgent: "新しいエージェント",
open: "{{name}}を開く",
openInSidePanel: "{{name}}をサイドパネルで開く",
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
addProject: "プロジェクトを追加",
home: "ホーム",
groupByProject: "プロジェクトでグループ化",
@@ -607,7 +610,7 @@ export const ja: TranslationResources = {
files: "ファイル",
pullRequest: "プルリクエスト",
terminalProfilesMenu: "ターミナルプロファイル",
editTerminalProfiles: "プロファイルを編集…",
editTerminalProfiles: "プロファイルを編集",
},
sidePanel: {
open: "サイドパネルを開く",
@@ -1717,6 +1720,7 @@ export const ja: TranslationResources = {
loading: "ファイルを読み込み中...",
noPreview: "プレビューが利用できません",
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
failedToLoad: "ファイルの読み込みに失敗しました",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
@@ -1745,6 +1749,7 @@ export const ja: TranslationResources = {
label: "ファイル",
subtitle: "ワークスペースのファイル",
tooltip: "ワークスペースのファイルを参照",
chooseFile: "ファイルを選択",
},
pullRequest: {
label: "プルリクエスト",
@@ -2365,10 +2370,9 @@ export const ja: TranslationResources = {
featuresLabel: "機能",
featureCount: "{{count}}個の機能",
featureCountOne: "{{count}}個の機能",
notesLabel: "エージェントへのメモ",
notesLabel: "使用する場面",
notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。",
notesHint:
"list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。",
notesHint: "別のエージェントを起動するとき、このプロファイルを選ぶために使用されます。",
save: "保存",
saving: "保存中...",
remove: "削除",
+8 -4
View File
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
workspaces: "워크스페이스",
agents: "에이전트",
newAgent: "새 에이전트",
open: "{{name}} 열기",
openInSidePanel: "사이드 패널에서 {{name}} 열기",
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
addProject: "프로젝트 추가",
home: "홈",
groupByProject: "프로젝트별 그룹화",
@@ -604,7 +607,7 @@ export const ko: TranslationResources = {
files: "파일",
pullRequest: "풀 리퀘스트",
terminalProfilesMenu: "터미널 프로필",
editTerminalProfiles: "프로필 편집…",
editTerminalProfiles: "프로필 편집",
},
sidePanel: {
open: "사이드 패널 열기",
@@ -1710,6 +1713,7 @@ export const ko: TranslationResources = {
loading: "파일 불러오는 중...",
noPreview: "사용 가능한 미리보기가 없습니다",
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
failedToLoad: "파일을 불러오지 못했습니다",
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
editor: {
@@ -1738,6 +1742,7 @@ export const ko: TranslationResources = {
label: "파일",
subtitle: "워크스페이스 파일",
tooltip: "워크스페이스 파일 탐색",
chooseFile: "파일 선택",
},
pullRequest: {
label: "풀 리퀘스트",
@@ -2354,10 +2359,9 @@ export const ko: TranslationResources = {
featuresLabel: "기능",
featureCount: "기능 {{count}}개",
featureCountOne: "기능 {{count}}개",
notesLabel: "에이전트를 위한 메모",
notesLabel: "사용 시점",
notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.",
notesHint:
"list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.",
notesHint: "에이전트가 다른 에이전트를 시작할 때 이 프로필을 선택하는 데 사용됩니다.",
save: "저장",
saving: "저장하는 중...",
remove: "제거",
+8 -4
View File
@@ -64,6 +64,9 @@ export const ptBR: TranslationResources = {
workspaces: "Espaços de trabalho",
agents: "Agentes",
newAgent: "Novo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} no painel lateral",
openInFocusedPane: "Abrir {{name}} no painel em foco",
addProject: "Adicionar projeto",
home: "Início",
groupByProject: "Agrupar por projeto",
@@ -606,7 +609,7 @@ export const ptBR: TranslationResources = {
files: "Arquivos",
pullRequest: "Pull request",
terminalProfilesMenu: "Perfis de terminal",
editTerminalProfiles: "Editar perfis…",
editTerminalProfiles: "Editar perfis",
},
sidePanel: {
open: "Abrir painel lateral",
@@ -1732,6 +1735,7 @@ export const ptBR: TranslationResources = {
loading: "Carregando arquivo...",
noPreview: "Nenhuma prévia disponível",
binaryPreviewUnavailable: "Prévia binária indisponível",
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
failedToLoad: "Falha ao carregar arquivo",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
@@ -1760,6 +1764,7 @@ export const ptBR: TranslationResources = {
label: "Arquivos",
subtitle: "Arquivos do espaço de trabalho",
tooltip: "Explorar arquivos do espaço de trabalho",
chooseFile: "Escolha um arquivo",
},
pullRequest: {
label: "Pull request",
@@ -2381,10 +2386,9 @@ export const ptBR: TranslationResources = {
featuresLabel: "Recursos",
featureCount: "{{count}} recursos",
featureCountOne: "{{count}} recurso",
notesLabel: "Notas para os agentes",
notesLabel: "Quando usar",
notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.",
notesHint:
'Retornado pela ferramenta MCP "list_profiles". Escreva como uma instrução para outro agente.',
notesHint: "Ajuda os agentes a escolher este perfil ao iniciar outro agente.",
save: "Salvar",
saving: "Salvando...",
remove: "Remover",
+8 -4
View File
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
workspaces: "Рабочие пространства",
agents: "Агенты",
newAgent: "Новый агент",
open: "Открыть {{name}}",
openInSidePanel: "Открыть {{name}} в боковой панели",
openInFocusedPane: "Открыть {{name}} в активной области",
addProject: "Добавить проект",
home: "Главная",
groupByProject: "Группировать по проекту",
@@ -607,7 +610,7 @@ export const ru: TranslationResources = {
files: "Файлы",
pullRequest: "PR",
terminalProfilesMenu: "Профили терминала",
editTerminalProfiles: "Изменить профили…",
editTerminalProfiles: "Изменить профили",
},
sidePanel: {
open: "Открыть боковую панель",
@@ -1730,6 +1733,7 @@ export const ru: TranslationResources = {
loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
tooLargeToDisplay: "Этот файл слишком велик для отображения",
failedToLoad: "Не удалось загрузить файл",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
@@ -1759,6 +1763,7 @@ export const ru: TranslationResources = {
label: "Файлы",
subtitle: "Файлы рабочего пространства",
tooltip: "Просмотр файлов рабочего пространства",
chooseFile: "Выберите файл",
},
pullRequest: {
label: "PR",
@@ -2386,10 +2391,9 @@ export const ru: TranslationResources = {
featuresLabel: "Функции",
featureCount: "Функции: {{count}}",
featureCountOne: "{{count}} функция",
notesLabel: "Заметки для агентов",
notesLabel: "Когда использовать",
notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.",
notesHint:
"MCP-инструмент «list_profiles» возвращает эти заметки. Сформулируйте их как инструкцию для другого агента.",
notesHint: "Помогает агентам выбрать этот профиль при запуске другого агента.",
save: "Сохранить",
saving: "Сохранение...",
remove: "Удалить",
+8 -3
View File
@@ -64,6 +64,9 @@ export const zhCN: TranslationResources = {
workspaces: "工作区",
agents: "Agents",
newAgent: "新建 Agent",
open: "打开{{name}}",
openInSidePanel: "在侧边面板中打开{{name}}",
openInFocusedPane: "在当前窗格中打开{{name}}",
addProject: "添加 project",
home: "首页",
groupByProject: "按项目分组",
@@ -602,7 +605,7 @@ export const zhCN: TranslationResources = {
files: "文件",
pullRequest: "拉取请求",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "打开侧边面板",
@@ -1680,6 +1683,7 @@ export const zhCN: TranslationResources = {
loading: "正在加载文件...",
noPreview: "没有可用预览",
binaryPreviewUnavailable: "二进制预览不可用",
tooLargeToDisplay: "此文件过大,无法显示",
failedToLoad: "加载文件失败",
failedToLoadPreview: "加载文件预览失败",
editor: {
@@ -1708,6 +1712,7 @@ export const zhCN: TranslationResources = {
label: "文件",
subtitle: "工作区文件",
tooltip: "浏览工作区文件",
chooseFile: "选择文件",
},
pullRequest: {
label: "拉取请求",
@@ -2316,9 +2321,9 @@ export const zhCN: TranslationResources = {
featuresLabel: "Features",
featureCount: "{{count}} 个 Feature",
featureCountOne: "{{count}} 个 Feature",
notesLabel: "给 Agent 的说明",
notesLabel: "适用场景",
notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。",
notesHint: "由 list_profiles MCP 工具返回。请将其写成给另一个 Agent 的指令。",
notesHint: "帮助 Agent 在启动另一个 Agent 时选择此配置。",
save: "保存",
saving: "保存中...",
remove: "移除",
@@ -1,5 +1,8 @@
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
export type WorkspacePanelTarget = "changes" | "files" | "pull-request";
export type WorkspacePanelPlacement = "supporting" | "side-panel" | "focused-pane";
export type KeyboardActionId =
| "agent.interrupt"
| "message-input.focus"
@@ -19,6 +22,15 @@ export type KeyboardActionId =
| "workspace.tab.close-current"
| "workspace.tab.navigate-index"
| "workspace.tab.navigate-relative"
| "workspace.tab.open"
| "workspace.tab.rename-current"
| "workspace.tab.reload-current"
| "workspace.tab.copy-resume-command"
| "workspace.tab.copy-id"
| "workspace.tab.copy-file-path"
| "workspace.tab.close-left"
| "workspace.tab.close-right"
| "workspace.tab.close-others"
| "workspace.pane.split.right"
| "workspace.pane.split.down"
| "workspace.pane.focus.left"
@@ -61,6 +73,20 @@ export type KeyboardActionDefinition =
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
| {
id: "workspace.tab.open";
scope: KeyboardActionScope;
target: WorkspacePanelTarget;
placement: WorkspacePanelPlacement;
}
| { id: "workspace.tab.rename-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.reload-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-resume-command"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-id"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-file-path"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-left"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-right"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-others"; scope: KeyboardActionScope }
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
@@ -1158,7 +1158,7 @@ describe("direct new-tab target shortcuts", () => {
const targetCases = [
["a", "KeyA", "workspace.tab.target.agent"],
["b", "KeyB", "workspace.tab.target.browser"],
["c", "KeyC", "workspace.tab.target.changes"],
["g", "KeyG", "workspace.tab.target.changes"],
["e", "KeyE", "workspace.tab.target.files"],
] as const;
@@ -1192,9 +1192,9 @@ describe("direct new-tab target shortcuts", () => {
it("uses the existing override map for target matching and display", () => {
const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac";
const overrides = { [bindingId]: "Ctrl+Shift+G" };
const overrides = { [bindingId]: "Ctrl+Shift+H" };
const rebound = resolveShortcut({
event: { key: "g", code: "KeyG", ctrlKey: true, shiftKey: true },
event: { key: "h", code: "KeyH", ctrlKey: true, shiftKey: true },
context: { ...desktopNonMac, focusScope: "other" },
bindings: buildEffectiveBindings(overrides),
});
@@ -1208,6 +1208,6 @@ describe("direct new-tab target shortcuts", () => {
expect(original.match).toBeNull();
expect(
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac),
).toEqual([["ctrl", "shift", "G"]]);
).toEqual([["ctrl", "shift", "H"]]);
});
});
@@ -454,9 +454,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
{
// Keep the binding id stable so saved overrides from the former Cmd+Shift+C default survive.
id: "workspace-tab-target-changes-cmd-shift-c-mac",
action: "workspace.tab.target.changes",
combo: "Cmd+Shift+C",
combo: "Cmd+Shift+G",
when: { mac: true, commandCenter: false },
help: {
id: "workspace-tab-target-changes",
@@ -465,9 +466,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
{
// Keep the binding id stable so saved overrides from the former Ctrl+Shift+C default survive.
id: "workspace-tab-target-changes-ctrl-shift-c-non-mac",
action: "workspace.tab.target.changes",
combo: "Ctrl+Shift+C",
combo: "Ctrl+Shift+G",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "workspace-tab-target-changes",
+39 -14
View File
@@ -25,7 +25,11 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { Composer } from "@/composer";
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import {
resolveComposerTrackControlClearance,
resolveComposerTrackTailClearance,
} from "@/composer/pill-styles";
import { getActiveMessageSubmissions } from "@/composer/submission/model";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import { getProviderIcon } from "@/components/provider-icons";
@@ -748,6 +752,7 @@ function AgentPanelBody({
return (
<ChatAgentContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
isPaneFocused={isPaneFocused}
client={client}
@@ -760,6 +765,7 @@ function AgentPanelBody({
function ChatAgentContent({
serverId,
workspaceId,
agentId,
isPaneFocused,
client,
@@ -768,6 +774,7 @@ function ChatAgentContent({
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId?: string;
isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
@@ -1164,6 +1171,7 @@ function ChatAgentContent({
return (
<ChatAgentReadyContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
@@ -1192,6 +1200,7 @@ function ChatAgentContent({
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId,
workspaceId,
agentId,
isPaneFocused,
isArchivingCurrentAgent,
@@ -1216,6 +1225,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId: string;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
@@ -1240,8 +1250,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor();
const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor);
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
@@ -1252,13 +1260,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
rows: subagentRows,
});
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const showAgentTracks =
hasActiveComposer &&
hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
});
const hasVisibleAgentTracks = hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
});
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
@@ -1329,18 +1335,22 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
bottomOverlayTailClearance={showAgentTracks ? composerTrackTailClearance : 0}
hasActiveComposer={hasActiveComposer}
hasVisibleAgentTracks={hasVisibleAgentTracks}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
{showAgentTracks ? (
{hasActiveComposer ? (
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
cwd={cwd}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
@@ -1409,24 +1419,38 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
serverId,
workspaceId,
agentId,
agent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
bottomOverlayTailClearance,
hasActiveComposer,
hasVisibleAgentTracks,
toast,
onOpenWorkspaceFile,
}: {
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
workspaceId: string;
agentId?: string;
agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
bottomOverlayTailClearance: number;
hasActiveComposer: boolean;
hasVisibleAgentTracks: boolean;
toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const isCompactFormFactor = useIsCompactFormFactor();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const hasVisibleComposerTracks =
hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat);
const bottomOverlayTailClearance = hasVisibleComposerTracks
? resolveComposerTrackTailClearance(isCompactFormFactor)
: 0;
const bottomOverlayControlClearance = hasVisibleComposerTracks
? resolveComposerTrackControlClearance(isCompactFormFactor)
: 0;
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
@@ -1483,6 +1507,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
bottomOverlayTailClearance={bottomOverlayTailClearance}
bottomOverlayControlClearance={bottomOverlayControlClearance}
toast={toast}
pendingMessageSubmissions={pendingMessageSubmissions}
turnPresentation={turnPresentation}
+31 -7
View File
@@ -1,4 +1,6 @@
import { memo, useCallback, type ReactElement } from "react";
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import { AgentTaskList } from "@/composer/task-list";
import { ComposerTrackBar } from "@/composer/tracks";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
@@ -15,29 +17,34 @@ import { SubagentsTrack } from "@/subagents/track";
import type { TodoEntry } from "@/types/stream";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSupportingTab } from "@/workspace-tabs/side-panel";
import { openSupportingTab, toggleSupportingTab } from "@/workspace-tabs/side-panel";
/**
* The pane's trackers — its subagents and its task list — as a row of pills above the composer.
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
* the composer.
*
* The row shares the composer's keyboard transform and owns the space between itself and the
* transcript. Its data remains agent state: a subagent row opens a tab and the task list reads
* the agent's stream.
* transcript. Each pill owns its action while tab placement stays behind the workspace boundary.
*/
export const AgentTracks = memo(function AgentTracks({
serverId,
workspaceId,
cwd,
subagentRows,
tasks,
archiveFinishedStatus,
onArchiveFinished,
}: {
serverId: string;
workspaceId: string;
cwd: string;
subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void;
}): ReactElement | null {
const { workspaceId, tabId, openTab } = usePaneContext();
const { tabId, openTab } = usePaneContext();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const canSplit = supportsDesktopPaneSplits() && !isCompact;
const openInSidePanelByDefault = useSettings(
@@ -87,13 +94,26 @@ export const AgentTracks = memo(function AgentTracks({
},
[canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey],
);
const handleOpenChanges = useCallback(() => {
if (!workspaceKey) {
return;
}
toggleSupportingTab({
isCompact,
workspaceKey,
checkout: { serverId, cwd, isGit: true },
target: { kind: "working_diff" },
openInSidePanelByDefault,
});
}, [cwd, isCompact, openInSidePanelByDefault, serverId, workspaceKey]);
if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
return null;
}
return (
<ComposerTrackBar>
<AgentTaskList tasks={tasks} />
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
@@ -103,7 +123,11 @@ export const AgentTracks = memo(function AgentTracks({
archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<AgentTaskList tasks={tasks} />
<WorkspaceDiffStatPill
serverId={serverId}
workspaceId={workspaceId}
onPress={handleOpenChanges}
/>
</ComposerTrackBar>
);
});
+42 -15
View File
@@ -1,6 +1,6 @@
import { Text, View } from "react-native";
import { FolderTree } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
@@ -9,14 +9,11 @@ import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { TreeRail } from "@/components/tree-rail";
import { useIsCompactFormFactor } from "@/constants/layout";
const ThemedFolderTree = withUnistyles(FolderTree);
const CENTERED_PADDED_STYLE = {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 16,
} as const;
const FILE_TREE_WIDTH = 220;
function useFilesPanelDescriptor() {
const { t } = useTranslation();
@@ -35,6 +32,7 @@ function FilesPanel() {
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
const isCompact = useIsCompactFormFactor();
invariant(target.kind === "files", "FilesPanel requires files target");
const onOpenFile = useCallback(
(path: string) => retargetCurrentTab({ kind: "file", path }),
@@ -42,19 +40,35 @@ function FilesPanel() {
);
if (!workspaceRoot) {
return (
<View style={CENTERED_PADDED_STYLE}>
<View style={styles.centerState}>
<Text>{t("panels.file.directoryMissing")}</Text>
</View>
);
}
if (isCompact) {
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
<TreeRail testID="files-tree-rail" width={FILE_TREE_WIDTH}>
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.files.chooseFile")}</Text>
</View>
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
</TreeRail>
);
}
@@ -64,3 +78,16 @@ export const filesPanelRegistration: PanelRegistration<"files"> = {
component: FilesPanel,
useDescriptor: useFilesPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -0,0 +1,8 @@
export type LauncherAction = "agent" | "terminal" | "changes" | "files" | "browser" | "pullRequest";
export function getLauncherActionOrder(isSidePanel: boolean): LauncherAction[] {
if (isSidePanel) {
return ["changes", "files", "terminal", "agent", "browser", "pullRequest"];
}
return ["agent", "terminal", "changes", "files", "browser", "pullRequest"];
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { getLauncherActionOrder } from "@/panels/new-tab-action-order";
describe("getLauncherActionOrder", () => {
it("leads with the work companion tools in the Side panel", () => {
expect(getLauncherActionOrder(true)).toEqual([
"changes",
"files",
"terminal",
"agent",
"browser",
"pullRequest",
]);
});
it("leads with creating work in an ordinary pane", () => {
expect(getLauncherActionOrder(false)).toEqual([
"agent",
"terminal",
"changes",
"files",
"browser",
"pullRequest",
]);
});
});
+558
View File
@@ -0,0 +1,558 @@
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactElement,
type ReactNode,
} from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import {
FileDiff,
FolderTree,
GitPullRequest,
Globe,
Pencil,
Plus,
SquarePen,
SquareTerminal,
type LucideIcon,
} from "lucide-react-native";
import { useRouter, type Href } from "expo-router";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { getLauncherActionOrder, type LauncherAction } from "@/panels/new-tab-action-order";
import type { PanelRegistration } from "@/panels/panel-registry";
import { resolvePluginIcon } from "@/plugins/icons";
import { useInstalledPlugins } from "@/plugins/registry";
import type { InstalledPlugin } from "@/plugins/types";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
export interface NewTabLauncher {
showChanges: boolean;
showPullRequest: boolean;
showBrowser: boolean;
terminalDisabled: boolean;
activate: (tabId: string, selection: NewTabSelection) => void;
}
const NewTabLauncherContext = createContext<NewTabLauncher | null>(null);
export function NewTabLauncherProvider({
value,
children,
}: {
value: NewTabLauncher;
children: ReactNode;
}) {
return <NewTabLauncherContext.Provider value={value}>{children}</NewTabLauncherContext.Provider>;
}
const ThemedPlus = withUnistyles(Plus);
const ThemedPencil = withUnistyles(Pencil);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
/** Every launcher row leads with the same glyph box so labels share one rail. */
const LAUNCHER_ICON_SIZE = ICON_SIZE.md;
const LAUNCHER_MAX_WIDTH = 380;
const EDIT_PROFILES_HIT_SIZE = ICON_SIZE.xs + SPACING[2];
const ROW_DATA_SET = { newTabLauncherRow: "true" };
const ROW_SELECTOR = '[data-new-tab-launcher-row="true"]';
const CHANGES_SELECTION: NewTabSelection = {
kind: "target",
target: { kind: "working_diff" },
};
const FILES_SELECTION: NewTabSelection = { kind: "target", target: { kind: "files" } };
const PULL_REQUEST_SELECTION: NewTabSelection = {
kind: "target",
target: { kind: "pull_request" },
};
const AGENT_SELECTION: NewTabSelection = { kind: "agent" };
const TERMINAL_SELECTION: NewTabSelection = { kind: "terminal" };
const BROWSER_SELECTION: NewTabSelection = { kind: "browser" };
interface LauncherActionDefinition {
label: string;
Icon: LucideIcon;
disabled?: boolean;
hidden?: boolean;
selection: NewTabSelection;
shortcut?: string;
testID: string;
}
function LauncherIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={LAUNCHER_ICON_SIZE} color={color} />;
}
const ThemedLauncherIcon = withUnistyles(LauncherIcon);
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
const keys = useShortcutKeys(actionId);
return keys ? <Shortcut chord={keys} /> : null;
}
function rowStyle({
pressed,
hovered = false,
focused = false,
}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) {
return [
styles.row,
(hovered || focused) && styles.rowHovered,
focused && styles.rowFocused,
pressed && styles.rowPressed,
];
}
function editProfilesStyle({ pressed, hovered }: PressableStateCallbackType) {
return [styles.editProfiles, (hovered || pressed) && styles.editProfilesHovered];
}
function EditProfilesButton({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={editProfilesStyle}
testID="workspace-new-tab-edit-terminal-profiles"
>
<View style={styles.editProfilesGlyph}>
<ThemedPencil size={ICON_SIZE.xs} uniProps={extraMutedColorMapping} />
</View>
</Pressable>
);
}
function LauncherRow({
label,
Icon,
terminalIconKey,
disabled,
selection,
shortcut,
testID,
}: {
label: string;
Icon?: LucideIcon;
terminalIconKey?: string;
disabled?: boolean;
selection: NewTabSelection;
shortcut?: string;
testID: string;
}) {
const { tabId } = usePaneContext();
const launcher = useContext(NewTabLauncherContext);
invariant(launcher, "NewTabLauncherProvider is required");
const handlePress = useCallback(() => {
launcher.activate(tabId, selection);
}, [launcher, selection, tabId]);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
dataSet={ROW_DATA_SET}
disabled={disabled}
onPress={handlePress}
style={rowStyle}
tabIndex={-1}
testID={testID}
>
{Icon ? <ThemedLauncherIcon Icon={Icon} uniProps={mutedColorMapping} /> : null}
{terminalIconKey ? (
<TerminalProfileIcon iconKey={terminalIconKey} size={LAUNCHER_ICON_SIZE} />
) : null}
<Text numberOfLines={1} style={styles.rowLabel}>
{label}
</Text>
{shortcut ? <LauncherShortcut actionId={shortcut} /> : null}
</Pressable>
);
}
function TerminalProfileLauncherRow({
profile,
disabled,
}: {
profile: TerminalProfile;
disabled: boolean;
}) {
const selection = useMemo<NewTabSelection>(() => ({ kind: "terminal", profile }), [profile]);
return (
<LauncherRow
label={profile.name}
terminalIconKey={getTerminalProfileIcon(profile)}
disabled={disabled}
selection={selection}
testID={`workspace-new-tab-terminal-profile-${profile.id}`}
/>
);
}
function PluginLauncherRow({
pluginId,
panel,
}: {
pluginId: string;
panel: PluginWorkspacePanelContribution;
}) {
const selection = useMemo<NewTabSelection>(
() => ({
kind: "target",
target: { kind: "plugin", pluginId, panelId: panel.id, context: "workspace" },
}),
[panel.id, pluginId],
);
return (
<LauncherRow
label={panel.title}
Icon={resolvePluginIcon(panel.icon)}
selection={selection}
testID={`workspace-new-tab-plugin-${pluginId}-${panel.id}`}
/>
);
}
function collectWorkspacePluginPanels(plugins: InstalledPlugin[], serverId: string) {
const result: Array<{ pluginId: string; panel: PluginWorkspacePanelContribution }> = [];
for (const plugin of plugins) {
if (plugin.serverId !== serverId) continue;
for (const panel of plugin.workspacePanels) {
if (panel.context === "workspace") {
result.push({ pluginId: plugin.id, panel });
}
}
}
return result;
}
function useNewTabDescriptor() {
const { t } = useTranslation();
const label = t("workspace.tabs.actions.newTab");
return {
label,
subtitle: label,
tooltip: label,
titleState: "ready" as const,
icon: ThemedPlus,
statusBucket: null,
};
}
const NewTabPanel = memo(function NewTabPanel(): ReactElement {
const { t } = useTranslation();
const router = useRouter();
const { isSidePanel, serverId, tabId } = usePaneContext();
const { isInteractive, focusPane } = usePaneFocus();
const launcher = useContext(NewTabLauncherContext);
invariant(launcher, "NewTabLauncherProvider is required");
const { config } = useDaemonConfig(serverId);
const plugins = useInstalledPlugins();
const containerRef = useRef<View | null>(null);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
const pluginPanels = useMemo(
() => collectWorkspacePluginPanels(plugins, serverId),
[plugins, serverId],
);
const editTerminalProfiles = useCallback(() => {
router.push(buildSettingsHostSectionRoute(serverId, "terminals") as Href);
}, [router, serverId]);
const launcherActions: Record<LauncherAction, LauncherActionDefinition> = {
agent: {
label: t("workspace.tabs.fallback.agent"),
Icon: SquarePen,
selection: AGENT_SELECTION,
shortcut: "workspace-tab-target-agent",
testID: "workspace-new-tab-agent",
},
terminal: {
label: t("workspace.tabs.fallback.terminal"),
Icon: SquareTerminal,
disabled: launcher.terminalDisabled,
selection: TERMINAL_SELECTION,
shortcut: "workspace-terminal-new",
testID: "workspace-new-tab-terminal",
},
changes: {
label: t("workspace.tabs.actions.changes"),
Icon: FileDiff,
hidden: !launcher.showChanges,
selection: CHANGES_SELECTION,
shortcut: "workspace-tab-target-changes",
testID: "workspace-new-tab-changes",
},
files: {
label: t("workspace.tabs.actions.files"),
Icon: FolderTree,
selection: FILES_SELECTION,
shortcut: "workspace-tab-target-files",
testID: "workspace-new-tab-files",
},
browser: {
label: t("workspace.tabs.fallback.browser"),
Icon: Globe,
hidden: !launcher.showBrowser,
selection: BROWSER_SELECTION,
shortcut: "workspace-tab-target-browser",
testID: "workspace-new-tab-browser",
},
pullRequest: {
label: t("workspace.tabs.actions.pullRequest"),
Icon: GitPullRequest,
hidden: !launcher.showPullRequest,
selection: PULL_REQUEST_SELECTION,
testID: "workspace-new-tab-pull-request",
},
};
useEffect(() => {
if (!isWeb || !isInteractive) return;
const container: unknown = containerRef.current;
if (!(container instanceof HTMLElement)) return;
const webContainer = container as HTMLElement;
function rows() {
return Array.from(webContainer.querySelectorAll(ROW_SELECTOR)) as HTMLElement[];
}
function focusFirstRow() {
rows()[0]?.focus({ preventScroll: true });
}
function handlePointerDown(event: PointerEvent) {
focusPane();
if (!(event.target instanceof Element) || !event.target.closest(ROW_SELECTOR)) {
requestAnimationFrame(focusFirstRow);
}
}
function handleKeyDown(event: KeyboardEvent) {
const availableRows = rows();
const currentIndex = availableRows.findIndex((row) => row === document.activeElement);
let direction = 0;
if (event.key === "ArrowDown") {
direction = 1;
} else if (event.key === "ArrowUp") {
direction = -1;
}
if (direction !== 0 && availableRows.length > 0) {
event.preventDefault();
let start = currentIndex;
if (currentIndex < 0) {
start = direction > 0 ? -1 : 0;
}
const nextIndex = (start + direction + availableRows.length) % availableRows.length;
availableRows[nextIndex]?.focus();
}
if (event.key === "Enter" && currentIndex >= 0) {
event.preventDefault();
availableRows[currentIndex]?.click();
}
}
webContainer.addEventListener("pointerdown", handlePointerDown, true);
webContainer.addEventListener("keydown", handleKeyDown);
focusFirstRow();
return () => {
webContainer.removeEventListener("pointerdown", handlePointerDown, true);
webContainer.removeEventListener("keydown", handleKeyDown);
};
}, [focusPane, isInteractive]);
const activate = useCallback(
(selection: NewTabSelection) => launcher.activate(tabId, selection),
[launcher, tabId],
);
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id === "workspace.agent.new" || action.id === "workspace.tab.target.agent") {
activate({ kind: "agent" });
return true;
}
if (action.id === "workspace.terminal.new") {
activate({ kind: "terminal" });
return true;
}
if (action.id === "workspace.browser.new" || action.id === "workspace.tab.target.browser") {
if (launcher.showBrowser) {
activate({ kind: "browser" });
}
return true;
}
if (action.id === "workspace.tab.target.changes") {
if (launcher.showChanges) {
activate({ kind: "target", target: { kind: "working_diff" } });
}
return true;
}
if (action.id === "workspace.tab.target.files") {
activate({ kind: "target", target: { kind: "files" } });
return true;
}
return false;
},
[activate, launcher.showBrowser, launcher.showChanges],
);
useKeyboardActionHandler({
handlerId: `new-tab:${tabId}`,
actions: [
"workspace.agent.new",
"workspace.terminal.new",
"workspace.browser.new",
"workspace.tab.target.agent",
"workspace.tab.target.browser",
"workspace.tab.target.changes",
"workspace.tab.target.files",
],
enabled: isInteractive,
priority: 250,
handle: handleKeyboardAction,
});
return (
<View ref={containerRef} style={styles.container} testID="workspace-new-tab-panel">
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.rail}>
<View style={styles.group}>
{getLauncherActionOrder(isSidePanel).map((actionId) => {
const action = launcherActions[actionId];
if (action.hidden) return null;
return <LauncherRow key={actionId} {...action} />;
})}
{pluginPanels.map(({ pluginId, panel }) => (
<PluginLauncherRow
key={`${pluginId}:${panel.id}`}
pluginId={pluginId}
panel={panel}
/>
))}
</View>
{profiles.length > 0 ? (
<View style={styles.group}>
<View style={styles.groupHeader}>
<Text style={styles.groupLabel}>
{t("workspace.tabs.actions.terminalProfilesMenu")}
</Text>
<EditProfilesButton
label={t("workspace.tabs.actions.editTerminalProfiles")}
onPress={editTerminalProfiles}
/>
</View>
{profiles.map((profile) => (
<TerminalProfileLauncherRow
key={profile.id}
disabled={launcher.terminalDisabled}
profile={profile}
/>
))}
</View>
) : null}
</View>
</ScrollView>
</View>
);
});
export const newTabPanelRegistration: PanelRegistration<"new_tab"> = {
kind: "new_tab",
resourceKey: () => "new_tab",
component: NewTabPanel,
useDescriptor: useNewTabDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[6],
},
rail: {
width: "100%",
maxWidth: LAUNCHER_MAX_WIDTH,
gap: theme.spacing[12],
},
group: {
gap: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 44,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
backgroundColor: theme.colors.surface1,
},
rowHovered: { backgroundColor: theme.colors.surface2 },
rowFocused: { borderColor: theme.colors.borderAccent },
rowPressed: { opacity: 0.85 },
rowLabel: { flex: 1, color: theme.colors.foreground },
// The section header shares the launcher rows' outer rails. The pencil's own
// padding is subtracted so its glyph — not its hover box — lands on the right rail.
groupHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 2,
marginBottom: theme.spacing[1],
},
groupLabel: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
// Without an explicit line height the font's ascent/descent split the text box
// unevenly and the label rides above the pencil beside it.
lineHeight: EDIT_PROFILES_HIT_SIZE,
},
// The box is the hover target; the negative margin cancels its growth on every
// side, so its layout footprint stays the glyph and the header keeps its height.
editProfiles: {
width: EDIT_PROFILES_HIT_SIZE,
height: EDIT_PROFILES_HIT_SIZE,
margin: -(EDIT_PROFILES_HIT_SIZE - ICON_SIZE.xs) / 2,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
outlineWidth: 0,
outlineColor: "transparent",
},
// Opacity sits on the whole glyph, not per path — the pencil's two strokes
// overlap and would render unevenly if each faded on its own.
editProfilesGlyph: {
opacity: theme.opacity[50],
},
editProfilesHovered: {
backgroundColor: theme.colors.surface2,
},
}));
+2
View File
@@ -7,6 +7,8 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue {
serverId: string;
workspaceId: string;
/** Whether this pane is the workspace's dedicated Side panel. */
isSidePanel: boolean;
tabId: string;
target: WorkspaceTabTarget;
state?: JsonValue;
@@ -10,6 +10,7 @@ import { terminalPanelRegistration } from "@/panels/terminal-panel";
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
import { pullRequestPanelRegistration } from "@/panels/pull-request-panel";
import { pluginPanelRegistration } from "@/plugins/workspace-panels/panel";
import { newTabPanelRegistration } from "@/panels/new-tab-panel";
let panelsRegistered = false;
@@ -18,6 +19,7 @@ export function ensurePanelsRegistered(): void {
return;
}
registerPanel(draftPanelRegistration);
registerPanel(newTabPanelRegistration);
registerPanel(agentPanelRegistration);
registerPanel(providerSubagentPanelRegistration);
registerPanel(setupPanelRegistration);
+2 -6
View File
@@ -20,7 +20,6 @@ import {
import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { Theme } from "@/styles/theme";
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
@@ -441,7 +440,6 @@ export function InlineReviewEditor({
}) {
const { t } = useTranslation();
const inputRef = useRef<EditingTextInputHandle | null>(null);
const focus = useWorkspaceFocusRestoration();
const [body, setBody] = useState(initialBody);
const [isFocused, setIsFocused] = useState(false);
const trimmedBody = body.trim();
@@ -452,13 +450,11 @@ export function InlineReviewEditor({
}, []);
const handleFocus = useCallback(() => {
focus.unfocus();
setIsFocused(true);
}, [focus]);
}, []);
const handleBlur = useCallback(() => {
setIsFocused(false);
focus.restore();
}, [focus]);
}, []);
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
useEffect(() => {
+4
View File
@@ -1390,6 +1390,10 @@ export class HostRuntimeStore {
return this.hostRegistryStatus;
}
recordUserActivity(): void {
this.replicaCache.recordUserActivity();
}
subscribeHostList(listener: () => void): () => void {
this.hostListListeners.add(listener);
return () => {
@@ -209,6 +209,35 @@ afterEach(() => {
});
describe("ReplicaCache", () => {
it("persists after user inactivity even while replica changes continue", async () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
seedSession();
await cache.flush();
cache.start();
const writesBeforeChange = storage.writes;
useSessionStore
.getState()
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("first", "First")]]]));
await vi.advanceTimersByTimeAsync(4_000);
cache.recordUserActivity();
await vi.advanceTimersByTimeAsync(1_000);
useSessionStore
.getState()
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("second", "Second")]]]));
await vi.advanceTimersByTimeAsync(3_999);
expect(storage.writes).toBe(writesBeforeChange);
await vi.advanceTimersByTimeAsync(1);
expect(storage.writes).toBe(writesBeforeChange + 1);
cache.setHosts([]);
});
it("persists focused replica changes without writing transient stream head updates", async () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
@@ -222,14 +251,14 @@ describe("ReplicaCache", () => {
useSessionStore
.getState()
.setAgentStreamHead(SERVER_ID, new Map([["agent-1", [message("live", "Streaming")]]]));
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(5_000);
expect(storage.writes).toBe(writesBeforeStream);
useSessionStore
.getState()
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("saved", "Committed")]]]));
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(5_000);
expect(storage.writes).toBe(writesBeforeStream + 1);
cache.setHosts([]);
+28 -11
View File
@@ -1,4 +1,5 @@
import { Buffer } from "buffer";
import { Keyboard } from "react-native";
import { z } from "zod";
import {
AgentStatusSchema,
@@ -23,7 +24,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 6;
const PERSIST_DELAY_MS = 750;
const PERSIST_AFTER_USER_INACTIVITY_MS = 5_000;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
const IsoDateSchema = z.iso.datetime();
@@ -805,17 +806,19 @@ export class ReplicaCache {
}
}
recordUserActivity(): void {
if (!this.persistTimer) return;
clearTimeout(this.persistTimer);
this.persistTimer = null;
this.schedulePersist();
}
start(): void {
if (this.unsubscribe) return;
const changedBeforeSubscription = this.captureSessions();
this.unsubscribe = useSessionStore.subscribe((state) => {
this.unsubscribe = useSessionStore.subscribe(() => {
if (this.activeServerIds.size === 0) return;
let changed = false;
for (const serverId of this.activeServerIds) {
const session = state.sessions[serverId];
if (session && this.captureHost(serverId, session)) changed = true;
}
if (changed) this.schedulePersist();
this.schedulePersist();
});
if (changedBeforeSubscription || this.needsPersist) this.schedulePersist();
}
@@ -856,15 +859,29 @@ export class ReplicaCache {
}
this.storedHosts.delete(serverId);
this.storedHosts.set(serverId, { ...stored, directorySync: checkpoint });
this.needsPersist = true;
this.schedulePersist();
}
async flush(): Promise<void> {
await this.persist(false);
}
private async flushPending(): Promise<void> {
if (Keyboard.isVisible()) {
this.schedulePersist();
return;
}
await this.persist(true);
}
private async persist(skipUnchanged: boolean): Promise<void> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
this.captureSessions();
const changed = this.captureSessions();
if (skipUnchanged && !changed && !this.needsPersist) return;
const bounded = this.buildBoundedPayload();
this.needsPersist = false;
if (!bounded) {
@@ -952,7 +969,7 @@ export class ReplicaCache {
if (this.persistTimer) return;
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
void this.flush();
}, PERSIST_DELAY_MS);
void this.flushPending();
}, PERSIST_AFTER_USER_INACTIVITY_MS);
}
}

Some files were not shown because too many files have changed in this diff Show More