mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675d825f6b | ||
|
|
1328e0cc05 | ||
|
|
f0ba64f23b | ||
|
|
22c27dd583 | ||
|
|
35da664a09 | ||
|
|
1323cb13c4 | ||
|
|
3287f2d00b | ||
|
|
d15a1451b7 | ||
|
|
9d370a18b8 | ||
|
|
865e25b3a9 | ||
|
|
82ab598426 | ||
|
|
09fae62888 | ||
|
|
7e0a220fde | ||
|
|
ed9a15c0bd | ||
|
|
b763d4358e | ||
|
|
5f2d4ac122 | ||
|
|
3bdc90a661 | ||
|
|
b5212a69c9 | ||
|
|
0bc903fa21 | ||
|
|
e2f20f0e24 | ||
|
|
30a225ce9f | ||
|
|
30dd54a318 | ||
|
|
abc8ad3fd4 | ||
|
|
4897627943 | ||
|
|
18533ef52b | ||
|
|
faa9c9c491 | ||
|
|
11f6494c20 | ||
|
|
9364da3414 | ||
|
|
ed5bc3091e | ||
|
|
8ff51eb176 | ||
|
|
90fb5e1f33 | ||
|
|
cda08ec033 | ||
|
|
21c585abec | ||
|
|
a1d0492d8d | ||
|
|
745f2e2768 | ||
|
|
ef55fecfe2 | ||
|
|
cc675da49c | ||
|
|
a9d5502fad | ||
|
|
849f49b622 | ||
|
|
d89f1ea663 | ||
|
|
5701543746 | ||
|
|
57db31c662 | ||
|
|
eadca4a96b | ||
|
|
4a111099c4 | ||
|
|
6f34699e4b | ||
|
|
aa6c521394 | ||
|
|
e80db346da | ||
|
|
26c7f3f5f3 | ||
|
|
0057ee7b72 | ||
|
|
0a3e55734b | ||
|
|
0b88383b01 | ||
|
|
a364edde17 | ||
|
|
22c6df3f85 | ||
|
|
93a3a742ac | ||
|
|
0abca5e0c7 | ||
|
|
f275474fec | ||
|
|
f12dc6127b | ||
|
|
e526f375c8 | ||
|
|
3ae1102d13 | ||
|
|
d1632d80e3 | ||
|
|
cad4110e24 | ||
|
|
fc7369384b | ||
|
|
a495138374 | ||
|
|
7f69166e97 | ||
|
|
2268790656 | ||
|
|
3a02d1cdd6 | ||
|
|
2e560677e7 | ||
|
|
b4a6b098a4 | ||
|
|
12f8c0db96 | ||
|
|
d39c95e0e7 | ||
|
|
f74031b50d | ||
|
|
8e67f5a707 | ||
|
|
a194a901b4 | ||
|
|
049edbfcaf | ||
|
|
7c18616160 | ||
|
|
70191ceaa8 | ||
|
|
99bac0fd27 | ||
|
|
e8b55eccab | ||
|
|
40ebd1b730 | ||
|
|
b5102e7d7e | ||
|
|
f3036ad2c9 | ||
|
|
4a01b9ec90 | ||
|
|
2f13806a2c | ||
|
|
5f5599a1a8 | ||
|
|
f2680563bd | ||
|
|
79d658243d | ||
|
|
3994fa65c0 | ||
|
|
10671cf999 | ||
|
|
40b9b4672e | ||
|
|
44fc4bfeff | ||
|
|
402f3e32ae | ||
|
|
436a595839 | ||
|
|
8a360281c6 |
1
.github/workflows/deploy-app.yml
vendored
1
.github/workflows/deploy-app.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'app-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
3
.github/workflows/server-ci.yml
vendored
3
.github/workflows/server-ci.yml
vendored
@@ -44,3 +44,6 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.30 - 2026-03-19
|
||||
|
||||
### Added
|
||||
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
|
||||
- Added a combined model selector and agent mode visuals across key UI surfaces.
|
||||
- Added Open Graph metadata improvements for richer website sharing previews.
|
||||
|
||||
### Improved
|
||||
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
|
||||
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
|
||||
- Improved project picker path display and general workspace UI polish.
|
||||
|
||||
### Fixed
|
||||
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
|
||||
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
|
||||
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
|
||||
|
||||
## 0.1.28 - 2026-03-15
|
||||
|
||||
### Added
|
||||
- Added OpenCode build and plan modes.
|
||||
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
||||
|
||||
### Improved
|
||||
- Improved the git action menu for more reliable repository actions.
|
||||
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
||||
- Updated the website hero copy and added a sponsor callout section.
|
||||
|
||||
### Fixed
|
||||
- Fixed assistant file links so they open the correct workspace files from chat.
|
||||
|
||||
## 0.1.27 - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
20
README.md
20
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">Manage coding agents from your phone and desktop.</p>
|
||||
<p align="center">One interface for all your coding agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
@@ -12,19 +12,27 @@
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> **Early development** — Features may break or change without notice. Use at your own risk.
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
|
||||
|
||||
### Headless / server mode
|
||||
|
||||
To run the daemon on a remote or headless machine:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then open the app and connect to your daemon.
|
||||
Then connect from the desktop app, mobile app, or CLI.
|
||||
|
||||
For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
@@ -57,4 +65,4 @@ npm run typecheck
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
AGPL-3.0
|
||||
|
||||
@@ -20,7 +20,7 @@ From repo root:
|
||||
```bash
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clean # Clean native project
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
Or from `packages/app`:
|
||||
@@ -34,8 +34,8 @@ APP_VARIANT=development npx expo run:android --variant=debug
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# Clean
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
341
docs/PANEL-REFACTOR-PLAN.md
Normal file
341
docs/PANEL-REFACTOR-PLAN.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Panel Interface Refactor Plan
|
||||
|
||||
**Goal:** Replace the hardcoded panel switch statements with a registry-based panel interface. This is a pure refactor — all product surfaces stay identical. The motivation is to prepare for split panes (VSCode-style), where each split independently renders panels.
|
||||
|
||||
## The Problem
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`, ~2084 lines) has a `renderContent()` function (line 1437) that switches on `target.kind` to render each panel type with bespoke props. The same pattern repeats in:
|
||||
|
||||
- `workspace-tab-model.ts` — switches on `target.kind` to build tab descriptors (labels, subtitles, status)
|
||||
- `workspace-tab-presentation.tsx` — switches on kind for icons and status indicators
|
||||
|
||||
Every new panel type requires editing 3+ files. This must become a registry where panels self-register.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### 1. PanelRegistration Interface
|
||||
|
||||
```typescript
|
||||
// panels/panel-registry.ts
|
||||
|
||||
interface PanelDescriptor {
|
||||
label: string;
|
||||
subtitle: string;
|
||||
titleState: "ready" | "loading";
|
||||
icon: React.ComponentType<{ size: number; color: string }>;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
}
|
||||
|
||||
interface PanelRegistration<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||
kind: K;
|
||||
component: React.ComponentType;
|
||||
useDescriptor(
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor;
|
||||
confirmClose?(
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Panel Registry
|
||||
|
||||
```typescript
|
||||
const panelRegistry = new Map<string, PanelRegistration>();
|
||||
|
||||
function registerPanel(registration: PanelRegistration): void {
|
||||
panelRegistry.set(registration.kind, registration);
|
||||
}
|
||||
|
||||
function getPanelRegistration(kind: string): PanelRegistration | undefined {
|
||||
return panelRegistry.get(kind);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. PaneContext
|
||||
|
||||
Every panel gets workspace-level context via `usePaneContext()`. No prop drilling of serverId/workspaceId through panel-specific props.
|
||||
|
||||
```typescript
|
||||
interface PaneContextValue {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
openTab(target: WorkspaceTabTarget): void;
|
||||
closeCurrentTab(): void;
|
||||
retargetCurrentTab(target: WorkspaceTabTarget): void;
|
||||
openFileInWorkspace(filePath: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. WorkspaceTabTarget stays unchanged
|
||||
|
||||
```typescript
|
||||
type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string };
|
||||
```
|
||||
|
||||
No store migration needed. `serverId` and `workspaceId` come from the pane context, not the target.
|
||||
|
||||
## Panel Implementations
|
||||
|
||||
Each panel type gets its own file that exports a `PanelRegistration`. Panels use `usePaneContext()` for workspace-level context and read their own data from stores directly.
|
||||
|
||||
### Agent Panel Example
|
||||
|
||||
```typescript
|
||||
// panels/agent-panel.ts
|
||||
|
||||
function useAgentPanelDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const agent = useSessionStore(
|
||||
(s) => s.agentsByServer.get(context.serverId)?.get(target.agentId) ?? null,
|
||||
);
|
||||
const provider = agent?.provider ?? "codex";
|
||||
const label = resolveAgentLabel(agent?.title);
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon: agentIconForProvider(provider),
|
||||
statusBucket: agent ? deriveAgentStatusBucket(agent) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function AgentPanel() {
|
||||
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={serverId}
|
||||
agentId={target.agentId}
|
||||
showExplorerSidebar={false}
|
||||
wrapWithExplorerSidebarProvider={false}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
||||
kind: "agent",
|
||||
component: AgentPanel,
|
||||
useDescriptor: useAgentPanelDescriptor,
|
||||
async confirmClose(target, context) {
|
||||
const agent = useSessionStore.getState().agentsByServer.get(context.serverId)?.get(target.agentId);
|
||||
if (agent?.status === "running") {
|
||||
return confirmDialog({ title: "Agent is still running. Close anyway?" });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Terminal Panel Example
|
||||
|
||||
```typescript
|
||||
function useTerminalPanelDescriptor(
|
||||
target: { kind: "terminal"; terminalId: string },
|
||||
_context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
// read terminal data from appropriate store
|
||||
return {
|
||||
label: "Terminal",
|
||||
subtitle: "Terminal",
|
||||
titleState: "ready",
|
||||
icon: TerminalIcon,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function TerminalPanel() {
|
||||
const { serverId, workspaceId, target, openTab } = usePaneContext();
|
||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||
return (
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceId}
|
||||
selectedTerminalId={target.terminalId}
|
||||
onSelectedTerminalIdChange={(terminalId) => {
|
||||
if (terminalId) {
|
||||
openTab({ kind: "terminal", terminalId });
|
||||
}
|
||||
}}
|
||||
hideHeader
|
||||
manageTerminalDirectorySubscription={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Draft Panel Example
|
||||
|
||||
```typescript
|
||||
function useDraftPanelDescriptor(
|
||||
_target: { kind: "draft"; draftId: string },
|
||||
_context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
return {
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
titleState: "ready",
|
||||
icon: PencilIcon,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function DraftPanel() {
|
||||
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = usePaneContext();
|
||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||
return (
|
||||
<WorkspaceDraftAgentTab
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
onCreated={(agentSnapshot) => {
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### File Panel Example
|
||||
|
||||
```typescript
|
||||
function useFilePanelDescriptor(
|
||||
target: { kind: "file"; path: string },
|
||||
_context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
|
||||
return {
|
||||
label: fileName,
|
||||
subtitle: target.path,
|
||||
titleState: "ready",
|
||||
icon: FileTextIcon,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function FilePanel() {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
invariant(target.kind === "file", "FilePanel requires file target");
|
||||
return (
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceId}
|
||||
filePath={target.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## How the Tab Bar Uses It
|
||||
|
||||
Each tab chip calls the panel's `useDescriptor` hook:
|
||||
|
||||
```typescript
|
||||
function TabChip({ tabId, target, serverId, workspaceId }: {
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const registration = getPanelRegistration(target.kind);
|
||||
invariant(registration, `No panel registration for kind: ${target.kind}`);
|
||||
const descriptor = registration.useDescriptor(target, { serverId, workspaceId });
|
||||
return (
|
||||
<TabChipChrome
|
||||
tabId={tabId}
|
||||
label={descriptor.label}
|
||||
subtitle={descriptor.subtitle}
|
||||
titleState={descriptor.titleState}
|
||||
icon={<descriptor.icon size={16} color={theme.colors.foregroundMuted} />}
|
||||
statusBucket={descriptor.statusBucket}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## How the Workspace Screen Renders Content
|
||||
|
||||
Replaces the entire `renderContent()` switch:
|
||||
|
||||
```typescript
|
||||
function PaneContent({ tabId, target, serverId, workspaceId }: {
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const registration = getPanelRegistration(target.kind);
|
||||
if (!registration) return null;
|
||||
const Component = registration.component;
|
||||
return (
|
||||
<PaneProvider value={{ serverId, workspaceId, tabId, target, ...actions }}>
|
||||
<Component />
|
||||
</PaneProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create panel registry infrastructure
|
||||
|
||||
Create the following new files:
|
||||
|
||||
- `packages/app/src/panels/panel-registry.ts` — `PanelRegistration`, `PanelDescriptor` types, registry map, `registerPanel()`, `getPanelRegistration()`
|
||||
- `packages/app/src/panels/pane-context.ts` — `PaneContextValue` type, React context, `PaneProvider`, `usePaneContext()` hook
|
||||
|
||||
### Step 2: Create panel registration files
|
||||
|
||||
Move panel-specific logic out of workspace-screen, workspace-tab-model, and workspace-tab-presentation into self-contained panel modules:
|
||||
|
||||
- `packages/app/src/panels/agent-panel.ts` — agent component wrapper + `useDescriptor` + `confirmClose`
|
||||
- `packages/app/src/panels/draft-panel.ts` — draft component wrapper + `useDescriptor`
|
||||
- `packages/app/src/panels/terminal-panel.ts` — terminal component wrapper + `useDescriptor`
|
||||
- `packages/app/src/panels/file-panel.ts` — file component wrapper + `useDescriptor`
|
||||
- `packages/app/src/panels/register-panels.ts` — imports all panels, calls `registerPanel()` for each
|
||||
|
||||
### Step 3: Refactor workspace-tab-model.ts
|
||||
|
||||
Replace the per-kind descriptor derivation in `deriveWorkspaceTabModel()` with calls to `getPanelRegistration(target.kind).useDescriptor(...)`.
|
||||
|
||||
Note: `deriveWorkspaceTabModel` is a pure function, not a hook. The `useDescriptor` hooks are called from React components (the tab bar). The model derivation may need to be restructured — the tab bar calls `useDescriptor` per tab, and the model just handles ordering and active-tab resolution.
|
||||
|
||||
### Step 4: Refactor workspace-screen.tsx renderContent()
|
||||
|
||||
Replace the `renderContent()` switch with `<PaneContent>` that uses the registry. Wire up the `PaneProvider` with the action callbacks that currently live as inline functions in the workspace screen.
|
||||
|
||||
### Step 5: Refactor workspace-tab-presentation.tsx
|
||||
|
||||
Move icon components and status derivation into each panel's registration. The shared `WorkspaceTabIcon` component becomes a thin wrapper that calls `registration.useDescriptor()` and renders the icon from the descriptor.
|
||||
|
||||
### Step 6: Verify
|
||||
|
||||
- `npm run typecheck` must pass
|
||||
- All existing tab behavior must work identically: open, close, reorder, retarget, keyboard shortcuts, context menus
|
||||
- Mobile tab switcher must work unchanged
|
||||
- No visual regressions in tab bar, icons, status indicators
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Pure refactor** — zero user-visible behavior changes
|
||||
- **No new features** — no splits, no new panel types, no new keyboard shortcuts
|
||||
- **WorkspaceTabTarget stays unchanged** — no store migration
|
||||
- **workspace-tabs-store.ts stays unchanged** — the store is not part of this refactor
|
||||
- **Do not create index.ts barrel files** — project convention
|
||||
- **Use `invariant` from `tiny-invariant`** for asserting panel target kinds
|
||||
- **Use `function` declarations** — project convention (no arrow function components)
|
||||
- **Use `interface` over `type` where possible** — project convention
|
||||
- **Run `npm run typecheck` after every change** — project rule
|
||||
@@ -32,6 +32,23 @@ npm run release:finalize # Publish npm, promote draft to published
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||
|
||||
To retry a failed workflow for an existing tag:
|
||||
|
||||
1. **Retry via `workflow_dispatch`** — all release workflows support `workflow_dispatch` with a `tag` input:
|
||||
```bash
|
||||
gh workflow run "Desktop Release" -f tag=v0.1.28 # all platforms
|
||||
gh workflow run "Desktop Release" -f tag=v0.1.28 -f platform=macos # single platform
|
||||
gh workflow run "Android APK Release" -f tag=v0.1.28
|
||||
gh workflow run "Deploy App" # no tag input needed
|
||||
```
|
||||
2. **Platform-specific retry tags** (desktop only) — push a tag like `desktop-macos-v0.1.28` to rebuild just that platform against the release tag's code
|
||||
|
||||
If the fix requires a code change (e.g. a broken build script), commit the fix to `main` and use `workflow_dispatch` pointing at the existing tag — the workflow checks out the tag ref, but for build-tooling fixes you may need to point it at `main` or cherry-pick the fix onto the tag.
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
|
||||
275
docs/SPLIT-PANES-PLAN.md
Normal file
275
docs/SPLIT-PANES-PLAN.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Split Panes Plan
|
||||
|
||||
**Goal:** VSCode-style split panes for the workspace screen. Users can drag tabs to edges to create horizontal/vertical splits, resize splits, and navigate between panes with keyboard shortcuts. Desktop/web only — mobile uses the same store but never creates splits (single pane).
|
||||
|
||||
## Data Model
|
||||
|
||||
### Core Types
|
||||
|
||||
```typescript
|
||||
interface SplitPane {
|
||||
id: string;
|
||||
tabIds: string[];
|
||||
focusedTabId: string | null;
|
||||
}
|
||||
|
||||
interface SplitGroup {
|
||||
id: string;
|
||||
direction: "horizontal" | "vertical";
|
||||
children: SplitNode[];
|
||||
sizes: number[]; // proportional, sum to 1, same length as children
|
||||
}
|
||||
|
||||
type SplitNode =
|
||||
| { kind: "pane"; pane: SplitPane }
|
||||
| { kind: "group"; group: SplitGroup };
|
||||
|
||||
interface WorkspaceLayout {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **Single store replaces the flat tab store.** The layout store owns tabs, tab order (per pane), and focused tab (per pane). No separate flat tab store.
|
||||
- **Mobile is just a single-pane tree.** Same store, same code paths. Mobile never calls split operations, so the tree never grows beyond one pane.
|
||||
- **Focused pane concept.** Common operations (`openTab`, `closeTab`, `focusTab`) route to the focused pane automatically. No `paneId` parameter needed for everyday use.
|
||||
- **`PaneContext` doesn't need `paneId`.** Split-specific operations (drag-drop, resize) are wired directly in split UI components that know their pane ID from tree rendering.
|
||||
- **Max depth: 4 levels.**
|
||||
- **Proportional sizes** that sum to 1. Minimum proportion per child: 0.1 (10%).
|
||||
|
||||
### Default State
|
||||
|
||||
Every workspace starts with:
|
||||
|
||||
```typescript
|
||||
{
|
||||
root: { kind: "pane", pane: { id: "main", tabIds: [], focusedTabId: null } },
|
||||
focusedPaneId: "main",
|
||||
}
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
Version 6 migration from the current flat tab store. Wraps existing `tabIds`, `tabOrder`, and `focusedTabId` into a single-pane tree.
|
||||
|
||||
## Store Actions
|
||||
|
||||
### Everyday Operations (pane-agnostic)
|
||||
|
||||
These don't take a `paneId`. Mobile code only uses these.
|
||||
|
||||
```typescript
|
||||
openTab(workspaceKey: string, target: WorkspaceTabTarget): string | null;
|
||||
closeTab(workspaceKey: string, tabId: string): void;
|
||||
focusTab(workspaceKey: string, tabId: string): void;
|
||||
retargetTab(workspaceKey: string, tabId: string, target: WorkspaceTabTarget): string | null;
|
||||
reorderTabs(workspaceKey: string, tabIds: string[]): void; // within focused pane
|
||||
getWorkspaceTabs(workspaceKey: string): WorkspaceTab[]; // all tabs across all panes
|
||||
```
|
||||
|
||||
- `openTab` creates the tab and adds it to the focused pane.
|
||||
- `closeTab` finds the tab in any pane, removes it. If that was the last tab in the pane, collapses the pane.
|
||||
- `focusTab` finds the tab in any pane, focuses it and focuses that pane.
|
||||
|
||||
### Split Operations (desktop only)
|
||||
|
||||
```typescript
|
||||
splitPane(workspaceKey: string, input: {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}): string | null; // new pane ID, or null if depth cap hit
|
||||
|
||||
moveTabToPane(workspaceKey: string, tabId: string, toPaneId: string): void;
|
||||
focusPane(workspaceKey: string, paneId: string): void;
|
||||
resizeSplit(workspaceKey: string, groupId: string, sizes: number[]): void;
|
||||
reorderTabsInPane(workspaceKey: string, paneId: string, tabIds: string[]): void;
|
||||
```
|
||||
|
||||
## Tree Transformations
|
||||
|
||||
### splitPane
|
||||
|
||||
**Position mapping:**
|
||||
- `left` / `right` → `horizontal` direction
|
||||
- `top` / `bottom` → `vertical` direction
|
||||
- `left` / `top` → new pane inserted before target
|
||||
- `right` / `bottom` → new pane inserted after target
|
||||
|
||||
**Optimization:** If the target pane's parent group has the same direction, insert as a sibling into that group instead of nesting. This keeps the tree flat.
|
||||
|
||||
```
|
||||
Before: horizontal([A, B])
|
||||
Split B right with tab X
|
||||
|
||||
Optimized: horizontal([A, B, C]) ← insert into existing group
|
||||
Naive: horizontal([A, horizontal([B, C])]) ← wastes depth
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Check depth — reject if would exceed 4 levels
|
||||
2. Remove `tabId` from source pane (could be same or different pane)
|
||||
3. Create new pane: `{ id: generateId(), tabIds: [tabId], focusedTabId: tabId }`
|
||||
4. If parent group has same direction → insert new pane adjacent to target in parent's children, split target's size proportion 50/50 between target and new pane
|
||||
5. Else → replace target node with new group `{ direction, children: [target, newPane], sizes: [0.5, 0.5] }` (order based on position)
|
||||
6. If source pane is now empty → collapse it
|
||||
7. Set `focusedPaneId` to new pane
|
||||
|
||||
### collapsePane
|
||||
|
||||
Triggered when a pane's last tab is removed or moved out.
|
||||
|
||||
```
|
||||
Before: horizontal([A, B, C]) sizes [0.3, 0.4, 0.3]
|
||||
B loses last tab
|
||||
|
||||
After: horizontal([A, C]) sizes [0.5, 0.5] (renormalized)
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Remove pane from parent group's children
|
||||
2. Remove corresponding entry from parent's sizes
|
||||
3. Renormalize sizes to sum to 1
|
||||
4. If parent group now has 1 child → unwrap: replace group with its single remaining child
|
||||
5. Unwrap can cascade up the tree
|
||||
6. Move focus to nearest sibling
|
||||
|
||||
### moveTabToPane
|
||||
|
||||
Tab dragged from one pane to another existing pane.
|
||||
|
||||
1. Remove `tabId` from source pane's `tabIds`
|
||||
2. Insert into target pane's `tabIds` at drop position (or end)
|
||||
3. Set target pane's `focusedTabId` to the moved tab
|
||||
4. If source pane is now empty → collapsePane
|
||||
5. Set `focusedPaneId` to target pane
|
||||
|
||||
### resizeSplit
|
||||
|
||||
User drags a divider between panes.
|
||||
|
||||
1. Find group by ID
|
||||
2. Update the two adjacent sizes based on drag delta
|
||||
3. Clamp each child to minimum proportion (0.1)
|
||||
4. Renormalize so sizes sum to 1
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Action | Shortcut |
|
||||
|---|---|
|
||||
| Split right | `Cmd+\` |
|
||||
| Split down | `Cmd+Shift+\` |
|
||||
| Focus pane left | `Cmd+Shift+←` |
|
||||
| Focus pane right | `Cmd+Shift+→` |
|
||||
| Focus pane up | `Cmd+Shift+↑` |
|
||||
| Focus pane down | `Cmd+Shift+↓` |
|
||||
| Move tab to pane left | `Cmd+Shift+Alt+←` |
|
||||
| Move tab to pane right | `Cmd+Shift+Alt+→` |
|
||||
| Move tab to pane up | `Cmd+Shift+Alt+↑` |
|
||||
| Move tab to pane down | `Cmd+Shift+Alt+↓` |
|
||||
| Close pane | `Cmd+Shift+W` |
|
||||
|
||||
Existing tab shortcuts unchanged — `Cmd+T`, `Cmd+W`, `Alt+Shift+[/]`, `Alt+1-9` — they operate on the focused pane's tabs.
|
||||
|
||||
## Drag and Drop UX
|
||||
|
||||
### Drop Zones
|
||||
|
||||
When dragging a tab over a pane, the pane is divided into 5 drop zones:
|
||||
- **Center** (inner 40%) — move tab to this pane (add to existing tab list)
|
||||
- **Left edge** (leftmost 15%) — split left
|
||||
- **Right edge** (rightmost 15%) — split right
|
||||
- **Top edge** (topmost 15%) — split up
|
||||
- **Bottom edge** (bottommost 15%) — split down
|
||||
|
||||
### Overlay Preview
|
||||
|
||||
On hover over a drop zone, show a semi-transparent overlay rectangle covering the half of the pane where the new split would appear. The overlay uses the theme's accent color at low opacity.
|
||||
|
||||
### Cross-Pane Tab Drag
|
||||
|
||||
Tabs can be dragged:
|
||||
- Within a pane's tab bar → reorder (existing behavior via SortableInlineList)
|
||||
- From one pane's tab bar to another pane's tab bar → move tab to that pane
|
||||
- From a tab bar to a pane's drop zone → split
|
||||
|
||||
When dragging the last tab out of a pane, the pane collapses after the drop completes.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Layout Store
|
||||
|
||||
Create `packages/app/src/stores/workspace-layout-store.ts`:
|
||||
- `WorkspaceLayout`, `SplitNode`, `SplitPane`, `SplitGroup` types
|
||||
- Zustand store with AsyncStorage persistence
|
||||
- Everyday actions: `openTab`, `closeTab`, `focusTab`, `retargetTab`, `reorderTabs`
|
||||
- Tree helpers: `findPaneById`, `findPaneContainingTab`, `getTreeDepth`, `collectAllTabs`
|
||||
- Version 6 migration from flat tab store
|
||||
|
||||
### Step 2: Migrate Workspace Screen to Layout Store
|
||||
|
||||
Replace all `useWorkspaceTabsStore` usage in workspace-screen with the new layout store. Mobile and desktop both use the layout store — mobile just never splits. All existing behavior preserved.
|
||||
|
||||
### Step 3: Split Tree Transformations
|
||||
|
||||
Add to the layout store:
|
||||
- `splitPane` with the parent-direction optimization and depth check
|
||||
- `collapsePane` with unwrap cascading
|
||||
- `moveTabToPane`
|
||||
- `resizeSplit`
|
||||
|
||||
Pure tree transformation functions, tested independently.
|
||||
|
||||
### Step 4: Split Container Component
|
||||
|
||||
Create `packages/app/src/components/split-container.tsx`:
|
||||
- Recursive component that renders `SplitNode`
|
||||
- Groups render as flex containers with direction from `SplitGroup.direction`
|
||||
- Panes render tab bar + active panel content (using the panel registry)
|
||||
- Resize handles between children of a group
|
||||
|
||||
### Step 5: Drop Zones and Overlay
|
||||
|
||||
Create `packages/app/src/components/split-drop-zone.tsx`:
|
||||
- Overlay that appears during tab drag
|
||||
- Divides pane into 5 zones (center + 4 edges)
|
||||
- Shows preview rectangle on hover
|
||||
- Calls `splitPane` or `moveTabToPane` on drop
|
||||
|
||||
### Step 6: Cross-Pane Drag
|
||||
|
||||
Extend the existing dnd-kit setup:
|
||||
- Tab bar items remain draggable (existing)
|
||||
- Pane drop zones become droppable targets
|
||||
- Tab bar of other panes become droppable targets (move to pane)
|
||||
- DndContext wraps the entire split container (not individual panes)
|
||||
|
||||
### Step 7: Keyboard Shortcuts
|
||||
|
||||
Register new actions in `keyboard/actions.ts`:
|
||||
- `workspace.pane.split.right`, `workspace.pane.split.down`
|
||||
- `workspace.pane.focus.left/right/up/down`
|
||||
- `workspace.pane.move-tab.left/right/up/down`
|
||||
- `workspace.pane.close`
|
||||
|
||||
Add bindings in `keyboard-shortcuts.ts` and handlers in the workspace screen.
|
||||
|
||||
### Step 8: Pane Focus Navigation
|
||||
|
||||
Implement spatial navigation for `focus.left/right/up/down`:
|
||||
- Walk the tree to find the focused pane's position in the layout
|
||||
- Find the nearest pane in the requested direction
|
||||
- Focus it
|
||||
|
||||
Same logic for `move-tab` shortcuts — find adjacent pane, call `moveTabToPane`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Mobile stays single-pane — same store, no special casing
|
||||
- Max 4 levels of nesting
|
||||
- Minimum pane size: 10% of parent
|
||||
- `PaneContext` interface unchanged — no `paneId` added
|
||||
- Panel registry unchanged — panels don't know about splits
|
||||
- Existing tab shortcuts work on focused pane, unchanged
|
||||
3858
package-lock.json
generated
3858
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.30",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -28,6 +29,7 @@
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
@@ -59,6 +61,8 @@
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
|
||||
@@ -100,6 +100,9 @@ export default {
|
||||
output: "single",
|
||||
favicon: "./assets/images/favicon.png",
|
||||
},
|
||||
autolinking: {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
@@ -143,6 +146,7 @@ export default {
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
reactCompiler: true,
|
||||
autolinkingModuleResolution: true,
|
||||
},
|
||||
extra: {
|
||||
router: {},
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectDaemonClient,
|
||||
createReplyTurn,
|
||||
expectDetachedFromBottom,
|
||||
expectNearBottom,
|
||||
getChatContainerKey,
|
||||
readScrollMetrics,
|
||||
scrollUpFromBottom,
|
||||
seedBottomAnchorAgent,
|
||||
waitForAgentReady,
|
||||
waitForContentGrowth,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
|
||||
test.describe.configure({ timeout: 180000 });
|
||||
|
||||
async function openWorkspaceAgentTab(page: Page, agentId: string) {
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
await tab.click();
|
||||
}
|
||||
|
||||
test("direct load and refresh land at the bottom for history-backed chats", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-direct-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a loaded chat restores bottom anchoring", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-switch-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.getByTestId("sidebar-new-agent").first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-sticky-${Date.now()}`,
|
||||
turnCount: 10,
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1320, height: 920 });
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.click();
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await composer.pressSequentially(`composer growth line ${index + 1}`);
|
||||
if (index < 5) {
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
}
|
||||
}
|
||||
await expectNearBottom(page);
|
||||
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 820, height: 760 });
|
||||
await expectNearBottom(page);
|
||||
|
||||
await scrollUpFromBottom(page, 720);
|
||||
await expectDetachedFromBottom(page);
|
||||
const beforeExternalUpdate = await readScrollMetrics(page);
|
||||
|
||||
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
|
||||
await client.sendAgentMessage(agent.id, externalTurn.message);
|
||||
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
|
||||
const finish = await client.waitForFinish(agent.id, 120000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expectDetachedFromBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||
});
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-virtualized-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.setViewportSize({ width: 780, height: 720 });
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
|
||||
"Not available"
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-details-persistence-session-id-value")
|
||||
).not.toHaveText("Not available", { timeout: 90_000 });
|
||||
|
||||
await page.getByTestId("agent-details-agent-id").click();
|
||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SERVER_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
|
||||
const AGENT_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
|
||||
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
|
||||
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
|
||||
const DAEMON_ENDPOINT =
|
||||
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
|
||||
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
|
||||
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
type ScrollMetrics = {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
|
||||
test.use({ browserName: "firefox" });
|
||||
|
||||
function seedDaemonRegistryScript(params: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const daemon = {
|
||||
serverId: params.serverId,
|
||||
label: "localhost",
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${params.endpoint}`,
|
||||
type: "direct",
|
||||
endpoint: params.endpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${params.endpoint}`,
|
||||
createdAt: params.nowIso,
|
||||
updatedAt: params.nowIso,
|
||||
};
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({
|
||||
serverId: params.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
contentHeight - (offsetY + viewportHeight)
|
||||
);
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollUpFromBottom(
|
||||
page: Page,
|
||||
pixels: number
|
||||
): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [
|
||||
rootElement,
|
||||
...Array.from(rootElement.querySelectorAll("*")),
|
||||
];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
}
|
||||
|
||||
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
||||
expect(userAgent).toContain("Firefox");
|
||||
|
||||
await page.addInitScript(seedDaemonRegistryScript, {
|
||||
serverId: SERVER_ID,
|
||||
endpoint: DAEMON_ENDPOINT,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Require enough history so the repro actually scrolls away from bottom.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
|
||||
})
|
||||
.toBeGreaterThan(300);
|
||||
|
||||
await scrollUpFromBottom(page, 900);
|
||||
await page.waitForTimeout(250);
|
||||
const beforeSubmit = await readScrollMetrics(page);
|
||||
const beforePath = testInfo.outputPath("before-submit.png");
|
||||
await page.screenshot({ path: beforePath, fullPage: true });
|
||||
await testInfo.attach("before-submit", {
|
||||
path: beforePath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const afterSubmit = await readScrollMetrics(page);
|
||||
const afterPath = testInfo.outputPath("after-submit.png");
|
||||
await page.screenshot({ path: afterPath, fullPage: true });
|
||||
await testInfo.attach("after-submit", {
|
||||
path: afterPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await testInfo.attach("firefox-scroll-metrics", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
url: AGENT_URL,
|
||||
submitText: SUBMIT_TEXT,
|
||||
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
|
||||
beforeScreenshot: beforePath,
|
||||
afterScreenshot: afterPath,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[firefox-scroll-repro] ${JSON.stringify(
|
||||
{
|
||||
submitText: SUBMIT_TEXT,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
|
||||
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
|
||||
NEAR_BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
function getChangesHeader(page: Page) {
|
||||
return getChangesScope(page).getByTestId('changes-header');
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
||||
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
||||
await primaryBackdrop.click({ force: true });
|
||||
await expect(primaryBackdrop).toHaveCount(0);
|
||||
}
|
||||
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
|
||||
if (await overflowBackdrop.isVisible().catch(() => false)) {
|
||||
await overflowBackdrop.click({ force: true });
|
||||
await expect(overflowBackdrop).toHaveCount(0);
|
||||
}
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
||||
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
|
||||
if (!(await modeToggle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
||||
if (current !== expected) {
|
||||
await modeToggle.click();
|
||||
const menu = page.getByTestId('changes-diff-status-menu');
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const optionTestId =
|
||||
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
|
||||
await page.getByTestId(optionTestId).click({ force: true });
|
||||
}
|
||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
await expect(caret).toBeVisible();
|
||||
await caret.click();
|
||||
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
|
||||
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesHeader(page);
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
return;
|
||||
}
|
||||
const changesScope = getChangesScope(page);
|
||||
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
|
||||
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
|
||||
if (!(await stopButton.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
await trigger.click();
|
||||
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(branchName);
|
||||
}
|
||||
|
||||
const preferredOption = menu
|
||||
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
|
||||
.first();
|
||||
await expect(preferredOption).toBeVisible({ timeout: 10000 });
|
||||
await preferredOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText(branchName, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function enableCreateWorktree(page: Page) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
|
||||
if (/Create new worktree/i.test(currentValue)) {
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
|
||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
||||
await createOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
await selectChangesView(page, 'base');
|
||||
await selectChangesView(page, 'working');
|
||||
}
|
||||
|
||||
async function refreshChangesTab(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
await page.getByTestId('explorer-tab-files').first().click();
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
|
||||
function normalizeTmpPath(value: string) {
|
||||
if (value.startsWith('/var/')) {
|
||||
return `/private${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
type GitWorktreeEntry = {
|
||||
worktreePath: string;
|
||||
branchRef: string | null;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
|
||||
const blocks = raw
|
||||
.split(/\n\s*\n/g)
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.length > 0);
|
||||
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
for (const block of blocks) {
|
||||
const worktreeMatch = block.match(/^worktree (.+)$/m);
|
||||
if (!worktreeMatch) {
|
||||
continue;
|
||||
}
|
||||
const branchMatch = block.match(/^branch (.+)$/m);
|
||||
entries.push({
|
||||
worktreePath: worktreeMatch[1].trim(),
|
||||
branchRef: branchMatch ? branchMatch[1].trim() : null,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const normalizedRepoPath = normalizeTmpPath(repoPath);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const output = execSync('git worktree list --porcelain', {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const entries = parseGitWorktreeList(output);
|
||||
const candidate = entries.find((entry) => {
|
||||
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
|
||||
if (normalizedWorktreePath === normalizedRepoPath) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.branchRef) {
|
||||
return false;
|
||||
}
|
||||
return !/\/main$/i.test(entry.branchRef);
|
||||
});
|
||||
|
||||
if (candidate) {
|
||||
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
|
||||
if (branchName) {
|
||||
return {
|
||||
worktreePath: candidate.worktreePath,
|
||||
branchName,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient git worktree read errors while polling.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
|
||||
}
|
||||
|
||||
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
||||
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await enableCreateWorktree(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
await expect
|
||||
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
|
||||
.not.toBe('Unknown');
|
||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
||||
|
||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
||||
repo.path
|
||||
);
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
realpath(repo.path).catch(() => repo.path),
|
||||
]);
|
||||
const normalizedRepo = normalizeTmpPath(resolvedRepo);
|
||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await selectAttachWorktree(page, worktreeBranch);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
||||
await expect(page.getByText('First change')).toBeVisible();
|
||||
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(primaryCta).toBeVisible();
|
||||
await expect(primaryCta).toContainText('Commit');
|
||||
|
||||
await primaryCta.click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push once from the menu so the branch has an origin/<branch> ref.
|
||||
await openChangesPrimaryMenu(page);
|
||||
await page.getByTestId('changes-menu-push').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
const notesPath = path.join(firstCwd, 'notes.txt');
|
||||
await writeFile(notesPath, 'Second change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await getChangesScope(page).getByTestId('changes-primary-cta').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
||||
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
||||
await pushPrimary.click();
|
||||
// Regression check: the primary CTA stays in place while pushing.
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
await page.waitForTimeout(50);
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
const count = execSync(
|
||||
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
|
||||
{ cwd: firstCwd, encoding: 'utf8' }
|
||||
).trim();
|
||||
return Number.parseInt(count, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(0);
|
||||
|
||||
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
|
||||
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
|
||||
execSync("git checkout main", { cwd: repo.path });
|
||||
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
||||
'Committed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await refreshChangesTab(page);
|
||||
|
||||
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
|
||||
// primary flow or hidden behind menu variants), so continue from a fresh draft.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgentInRepo } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('create agent in a temp repo', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt });
|
||||
|
||||
// Verify user message is shown in the stream
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
|
||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
||||
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
|
||||
await expect(modelPicker).toBeVisible({ timeout: 30000 });
|
||||
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
|
||||
|
||||
// Verify the assistant response is rendered.
|
||||
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('daemon is connected in settings', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const nonce = Math.random().toString(36).slice(2, 10);
|
||||
const prompt = `respond-ready-${nonce}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
// Create agent (via message input) so it shows up in the sidebar list.
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
||||
|
||||
const match =
|
||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
|
||||
// Return home and delete via long-press in the agent list.
|
||||
await gotoHome(page);
|
||||
const rowTestId = `agent-row-${serverId}-${agentId}`;
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
|
||||
await agentRow.hover();
|
||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||
await quickArchive.click({ force: true });
|
||||
const confirmArchive = page
|
||||
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
|
||||
.first();
|
||||
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
|
||||
await confirmArchive.click({ force: true });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
// A full reload should not bring the agent back.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,342 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
createAgentInRepo,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
openSettings,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from '@/utils/host-routes';
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function addFakeMicrophone(page: Page) {
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
||||
const mimeType = 'audio/wav';
|
||||
|
||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
||||
const mic = {
|
||||
active: 0,
|
||||
getUserMediaCalls: 0,
|
||||
stopCalls: 0,
|
||||
lastRecorder: null as null | { state: string },
|
||||
};
|
||||
(window as any).__mic = mic;
|
||||
|
||||
(window as any).isSecureContext = true;
|
||||
|
||||
const nav = navigator as any;
|
||||
if (!nav.mediaDevices) {
|
||||
nav.mediaDevices = {};
|
||||
}
|
||||
nav.mediaDevices.getUserMedia = async () => {
|
||||
mic.getUserMediaCalls += 1;
|
||||
mic.active += 1;
|
||||
const track = {
|
||||
stop: () => {
|
||||
mic.stopCalls += 1;
|
||||
mic.active = Math.max(0, mic.active - 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
getTracks: () => [track],
|
||||
};
|
||||
};
|
||||
|
||||
const AudioContextCtor =
|
||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
||||
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
|
||||
const nativeCreateMediaStreamSource =
|
||||
AudioContextCtor.prototype.createMediaStreamSource;
|
||||
AudioContextCtor.prototype.createMediaStreamSource =
|
||||
function patchedCreateMediaStreamSource(stream: unknown) {
|
||||
const isFakeStream =
|
||||
!!stream &&
|
||||
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
|
||||
typeof (stream as { id?: unknown }).id === 'undefined';
|
||||
if (isFakeStream) {
|
||||
throw new Error('Force recorder fallback for fake microphone stream');
|
||||
}
|
||||
return nativeCreateMediaStreamSource.call(this, stream);
|
||||
};
|
||||
}
|
||||
|
||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
};
|
||||
|
||||
class FakeMediaRecorder extends EventTarget {
|
||||
public static isTypeSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public state: 'inactive' | 'recording' = 'inactive';
|
||||
public mimeType: string;
|
||||
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||
public onerror: ((event: unknown) => void) | null = null;
|
||||
|
||||
constructor(_stream: unknown, options?: MediaRecorderOptions) {
|
||||
super();
|
||||
this.mimeType = options?.mimeType ?? 'audio/webm';
|
||||
mic.lastRecorder = this;
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error('Not recording');
|
||||
}
|
||||
this.state = 'inactive';
|
||||
try {
|
||||
this.ondataavailable?.({
|
||||
data: blobFromBase64(base64Audio, mimeType),
|
||||
});
|
||||
} catch (err) {
|
||||
this.onerror?.(err);
|
||||
}
|
||||
this.dispatchEvent(new Event('stop'));
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
||||
}, { base64Audio, mimeType });
|
||||
}
|
||||
|
||||
async function expectComposerReady(page: Page) {
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
|
||||
}
|
||||
|
||||
async function expectDictationStarted(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
}
|
||||
|
||||
async function expectDictationStopped(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click();
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await openSettings(page);
|
||||
await page.keyboard.press('Control+d');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
||||
|
||||
expect(calls).toBe(0);
|
||||
expect(active).toBe(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const initialCopyMessageCount = await page
|
||||
.getByRole('button', { name: 'Copy message' })
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStopped(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
.toBeGreaterThan(initialCopyMessageCount);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
||||
if (mic.lastRecorder) {
|
||||
mic.lastRecorder.state = 'inactive';
|
||||
}
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]!);
|
||||
const agentId = decodeURIComponent(match[2]!);
|
||||
await expectComposerReady(page);
|
||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||
await expect(newAgentButton).toBeVisible();
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
|
||||
|
||||
await page.waitForTimeout(10_000);
|
||||
|
||||
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
|
||||
);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
|
||||
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("draft enables explorer after selecting a working directory", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
|
||||
const toggle = page
|
||||
.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
})
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="explorer-header"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const terminalsTab = page
|
||||
.locator('[data-testid="explorer-tab-terminals"]:visible')
|
||||
.first();
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="terminal-surface"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
buildCreateAgentPreferences,
|
||||
buildSeededHost,
|
||||
} from './helpers/daemon-registry';
|
||||
|
||||
// Extend base test to provide dynamic baseURL from global-setup
|
||||
const test = base.extend({
|
||||
@@ -58,31 +62,12 @@ test.beforeEach(async ({ page }) => {
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
|
||||
}
|
||||
const testDaemon = {
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const createAgentPreferences = {
|
||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
||||
serverId: testDaemon.serverId,
|
||||
// Keep e2e fast/cheap by default.
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce }) => {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const currentLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(firstTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
||||
|
||||
const nextLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(secondTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-sticky-');
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(repo.path, 'README.md');
|
||||
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
|
||||
await appendFile(readmePath, `\n${lines}`);
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
|
||||
await fileToggle.click();
|
||||
|
||||
const markerLine = scope.getByText('Sticky header line 250').first();
|
||||
await expect(markerLine).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const scroll = scope.getByTestId('git-diff-scroll').first();
|
||||
await expect(scroll).toBeVisible();
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
|
||||
}).toBe(true);
|
||||
|
||||
await scroll.hover();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.mouse.wheel(0, 700);
|
||||
}
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => el.scrollTop ?? 0);
|
||||
}).toBeGreaterThan(0);
|
||||
|
||||
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(fileToggle).toBeVisible();
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
|
||||
const prompt = `global draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(prompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,219 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
|
||||
|
||||
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
|
||||
const url = new URL(rawUrl)
|
||||
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
|
||||
const workspaceToken = pathMatch?.[1] ?? null
|
||||
let agentId = pathMatch?.[2] ?? null
|
||||
|
||||
if (!agentId) {
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
if (openMatch?.[1]) {
|
||||
agentId = openMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
return { workspaceToken, agentId }
|
||||
}
|
||||
|
||||
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
|
||||
const url = new URL(rawUrl)
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
return openMatch?.[1] ?? null
|
||||
}
|
||||
|
||||
async function preferSlowerThinkingOption(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
await thinkingTrigger.click({ force: true })
|
||||
const menu = page.getByTestId('agent-thinking-menu').first()
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
|
||||
const preferred = ['high', 'max', 'deep', 'long', 'medium']
|
||||
for (const label of preferred) {
|
||||
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true })
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const options = menu.getByRole('button')
|
||||
const count = await options.count()
|
||||
if (count > 0) {
|
||||
await options.last().click({ force: true })
|
||||
}
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function isAnyStopVisible(page: Page): Promise<boolean> {
|
||||
const candidates = page.getByRole('button', { name: /stop|cancel/i })
|
||||
const count = await candidates.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await candidates.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function isRunningControlVisible(page: Page): Promise<boolean> {
|
||||
if (await isAnyStopVisible(page)) {
|
||||
return true
|
||||
}
|
||||
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
|
||||
const count = await interruptSendButton.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
|
||||
const locator = page.getByText(matcher).first()
|
||||
const matches = page.getByText(matcher)
|
||||
const count = await matches.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await matches.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return await locator.isVisible().catch(() => false)
|
||||
}
|
||||
|
||||
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(360_000)
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
|
||||
const id = `${Date.now()}`
|
||||
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
|
||||
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
|
||||
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
|
||||
const seedToken = `ACK_SEED_${id}`
|
||||
const secondToken = `ACK_SECOND_${id}`
|
||||
expect(seedPrompt).not.toBe(secondPrompt)
|
||||
expect(seedToken).not.toBe(secondToken)
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// 1) Seed workspace with an existing agent.
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await seedComposer.press('Enter')
|
||||
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const seededContext = parseWorkspaceContextFromUrl(page.url())
|
||||
if (!seededContext.workspaceToken || !seededContext.agentId) {
|
||||
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const seededAgentId = seededContext.agentId
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
|
||||
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
|
||||
|
||||
// 2) Use global New Agent entry and 3) select same workspace.
|
||||
await page.getByText('New agent', { exact: true }).first().click()
|
||||
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
await preferSlowerThinkingOption(page)
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
|
||||
// 4) Create second agent from global draft.
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(secondPrompt)
|
||||
await composer.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
if (page.url().includes('/new-agent')) {
|
||||
const sendButton = page.getByRole('button', { name: /send message/i }).first()
|
||||
await expect(sendButton).toBeVisible({ timeout: 10_000 })
|
||||
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
|
||||
await sendButton.click({ force: true })
|
||||
}
|
||||
|
||||
// 5) Assert redirect workspace context + created agent tab is active/open.
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const createdContext = parseWorkspaceContextFromUrl(page.url())
|
||||
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
|
||||
if (!createdContext.agentId) {
|
||||
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const createdAgentId = createdContext.agentId
|
||||
expect(createdAgentId).toBeTruthy()
|
||||
expect(createdAgentId).not.toBe(seededAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
|
||||
|
||||
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const finalTabIds = await getWorkspaceTabTestIds(page)
|
||||
return finalTabIds.includes(createdTabTestId)
|
||||
})
|
||||
.toBe(true)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
|
||||
// 6) Response assertions are scoped to created-agent active context.
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// 7) Lifecycle assertions on created agent only: running first, then idle.
|
||||
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await lifecycleComposer.fill(lifecyclePrompt)
|
||||
await lifecycleComposer.press('Enter')
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
|
||||
let sawRunning = false
|
||||
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
||||
if (await isRunningControlVisible(page)) {
|
||||
sawRunning = true
|
||||
break
|
||||
}
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
expect(sawRunning).toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
|
||||
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -199,6 +199,19 @@ function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
|
||||
if (existsSync(relayDistEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[e2e] Building @getpaseo/relay for daemon startup');
|
||||
execSync('npm run build --workspace=@getpaseo/relay', {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
const marker = '#offer=';
|
||||
const idx = url.indexOf(marker);
|
||||
@@ -217,6 +230,7 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
const envTestPath = path.join(repoRoot, '.env.test');
|
||||
if (existsSync(envTestPath)) {
|
||||
dotenv.config({ path: envTestPath });
|
||||
|
||||
@@ -66,17 +66,17 @@ function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string): string {
|
||||
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label),
|
||||
buildReplyBlock(label, lineCount),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string): string {
|
||||
return ["REPLY:", buildReplyBlock(label)].join("\n");
|
||||
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
@@ -124,9 +124,11 @@ export async function seedBottomAnchorAgent(input: {
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
lineCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
@@ -134,7 +136,7 @@ export async function seedBottomAnchorAgent(input: {
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||
});
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
@@ -143,11 +145,11 @@ export async function seedBottomAnchorAgent(input: {
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
|
||||
expectedTailText = buildReplyBlock(label, lineCount);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
@@ -165,16 +167,29 @@ export async function seedBottomAnchorAgent(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||
.filter((element) => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const isEditable =
|
||||
tagName === "textarea" ||
|
||||
tagName === "input" ||
|
||||
element.getAttribute("contenteditable") === "true";
|
||||
return !isEditable && element.scrollHeight - element.clientHeight > 1;
|
||||
});
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
right.scrollHeight -
|
||||
right.clientHeight -
|
||||
(left.scrollHeight - left.clientHeight)
|
||||
)[0] ?? (root as HTMLElement);
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
@@ -194,29 +209,33 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
const scrollViewport = getVisibleChatScroll(page);
|
||||
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||
let remaining = Math.max(0, pixels);
|
||||
while (remaining > 0) {
|
||||
const delta = Math.min(240, remaining);
|
||||
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||
const scrollContainer = element as HTMLElement;
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -step,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, delta);
|
||||
remaining -= delta;
|
||||
|
||||
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
|
||||
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
@@ -263,9 +282,7 @@ export async function waitForContentGrowth(
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return page
|
||||
.getByTestId("agent-chat-scroll")
|
||||
.evaluate((element) => {
|
||||
return getVisibleChatScroll(page).evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
buildCreateAgentPreferences,
|
||||
buildSeededHost,
|
||||
} from './daemon-registry';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -33,8 +37,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
if (entry?.serverId !== expectedServerId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
||||
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
|
||||
if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
||||
return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -45,42 +49,20 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const daemon = buildSeededHost({
|
||||
serverId: expectedServerId,
|
||||
endpoint: expectedEndpoint,
|
||||
nowIso,
|
||||
});
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ expectedEndpoint, nowIso, expectedServerId }) => {
|
||||
({ daemon, preferences }) => {
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem(
|
||||
'@paseo:daemon-registry',
|
||||
JSON.stringify([
|
||||
{
|
||||
serverId: expectedServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${expectedEndpoint}`,
|
||||
type: 'direct',
|
||||
endpoint: expectedEndpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${expectedEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
},
|
||||
])
|
||||
);
|
||||
localStorage.setItem(
|
||||
'@paseo:create-agent-preferences',
|
||||
JSON.stringify({
|
||||
serverId: expectedServerId,
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
})
|
||||
);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ expectedEndpoint, nowIso, expectedServerId }
|
||||
{ daemon, preferences }
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
@@ -128,13 +110,13 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
const connections: unknown = daemon?.connections;
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
|
||||
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
||||
);
|
||||
}
|
||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
||||
}
|
||||
|
||||
@@ -154,14 +136,36 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
await ensureE2EStorageSeeded(page);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
};
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await gotoAppShell(page);
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
if (!(await composer.first().isVisible().catch(() => false))) {
|
||||
const addProjectCta = page.getByText('Add a project', { exact: true }).first();
|
||||
const addProjectSidebar = page.getByText('Add project', { exact: true }).first();
|
||||
const newAgentButton = page.getByText('New agent', { exact: true }).first();
|
||||
await newAgentButton.click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||
await addProjectCta.click();
|
||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||
await addProjectSidebar.click();
|
||||
} else {
|
||||
await newAgentButton.click();
|
||||
}
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
|
||||
39
packages/app/e2e/helpers/daemon-registry.ts
Normal file
39
packages/app/e2e/helpers/daemon-registry.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp" as const,
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeededHost(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
label?: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const connection = buildDirectTcpConnection(input.endpoint);
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.label ?? TEST_HOST_LABEL,
|
||||
connections: [connection],
|
||||
preferredConnectionId: connection.id,
|
||||
createdAt: input.nowIso,
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
|
||||
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
// First-time connection prompts for an optional label.
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
const showedNameModal = await nameModal
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (showedNameModal) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const seededServerId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!seededServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const extraPort = Number(daemonPort) + 1;
|
||||
const extraEndpoint = `127.0.0.1:${extraPort}`;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const extraDaemon = {
|
||||
serverId: 'srv_e2e_extra_daemon',
|
||||
label: 'extra',
|
||||
connections: [
|
||||
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
|
||||
],
|
||||
preferredConnectionId: `direct:${extraEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seededTestDaemon = {
|
||||
serverId: seededServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Add a second host once (fixtures seed the primary host on every navigation).
|
||||
await page.addInitScript(
|
||||
({ daemon, seededTestDaemon, seedOnceKey }) => {
|
||||
if (localStorage.getItem(seedOnceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
let parsed: any[] = [];
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
localStorage.setItem(seedOnceKey, '1');
|
||||
|
||||
const next = [...list];
|
||||
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
|
||||
if (!hasSeeded) {
|
||||
next.push(seededTestDaemon);
|
||||
}
|
||||
|
||||
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
|
||||
if (!alreadyPresent) {
|
||||
next.push(daemon);
|
||||
}
|
||||
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
|
||||
},
|
||||
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
|
||||
);
|
||||
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
|
||||
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
|
||||
await hostSettingsButton.click();
|
||||
|
||||
const hostDetailModal = page.getByTestId('host-detail-modal');
|
||||
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
|
||||
await hostDetailModal.getByText('Advanced', { exact: true }).click();
|
||||
await page.getByText('Remove host', { exact: true }).last().click();
|
||||
|
||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||
await page.getByTestId('remove-host-confirm').click();
|
||||
|
||||
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
await page.waitForFunction(
|
||||
(serverId) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
extraDaemon.serverId,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Prevent the fixture from overwriting storage on reload; verify persistence.
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome } from './helpers/app';
|
||||
|
||||
test('new agent auto-selects the previous host', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await gotoHome(page);
|
||||
|
||||
// The selected host should be restored after a full reload without manual selection.
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Ensure this test's storage is deterministic even under parallel load.
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
const createAgentPreferences = {
|
||||
serverId: testDaemon.serverId,
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
await page.goto('/settings');
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
|
||||
if (await newAgentButton.isVisible().catch(() => false)) {
|
||||
await newAgentButton.click();
|
||||
} else {
|
||||
await page.getByText('New agent', { exact: true }).first().click();
|
||||
}
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
await gotoHome(page);
|
||||
await page.evaluate(
|
||||
({ daemon }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon }
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Host should be auto-selected (no manual selection required).
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await page.getByTestId("menu-button").first().focus();
|
||||
|
||||
await page.keyboard.press("Shift+/");
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
||||
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toContainText("Show keyboard shortcuts");
|
||||
await expect(content).toContainText("Toggle left sidebar");
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this navigation (must run before app boot).
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'welcome';
|
||||
}
|
||||
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'settings';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.not.toBe('');
|
||||
|
||||
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
||||
if (isWelcome) {
|
||||
await page.getByText('Direct connection', { exact: true }).first().click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('host:6767');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
if (await nameModal.isVisible().catch(() => false)) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible({ timeout: 10000 });
|
||||
await settingsButton.click();
|
||||
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
({ port, serverId }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
||||
const entry = parsed[0];
|
||||
return (
|
||||
entry?.serverId === serverId &&
|
||||
Array.isArray(entry?.connections) &&
|
||||
entry.connections.some(
|
||||
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ port: daemonPort, serverId },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
function encodeBase64Url(input: string): string {
|
||||
return Buffer.from(input, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.goto('/');
|
||||
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const offer = {
|
||||
v: 2 as const,
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint },
|
||||
};
|
||||
|
||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
||||
|
||||
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
||||
if (await welcomeTitle.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('welcome-paste-pairing-link').click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(offerUrl);
|
||||
|
||||
await page.getByTestId('pair-link-submit').click();
|
||||
|
||||
const nameHostModal = page.getByTestId('name-host-modal');
|
||||
if (await nameHostModal.isVisible().catch(() => false)) {
|
||||
await nameHostModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(
|
||||
({ expected }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
||||
const entry = parsed[0];
|
||||
const relayId = `relay:${expected.relay.endpoint}`;
|
||||
return (
|
||||
entry?.serverId === expected.serverId &&
|
||||
Array.isArray(entry?.connections) &&
|
||||
entry.connections.length === 1 &&
|
||||
entry.connections[0]?.id === relayId &&
|
||||
entry.connections[0]?.type === 'relay' &&
|
||||
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
|
||||
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: offer },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("pastes clipboard image into prompt attachments", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-paste-image-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable();
|
||||
await input.focus();
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLTextAreaElement)) {
|
||||
return {
|
||||
pasted: false,
|
||||
elementTag: active ? active.tagName : null,
|
||||
defaultPrevented: false,
|
||||
};
|
||||
}
|
||||
|
||||
const file = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
active.dispatchEvent(event);
|
||||
|
||||
return {
|
||||
pasted: true,
|
||||
elementTag: active.tagName,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.pasted).toBe(true);
|
||||
expect(result.defaultPrevented).toBe(true);
|
||||
await expect(page.getByTestId("message-input-image-pill")).toHaveCount(1);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgentWithConfig,
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentFinishUI,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
const FILE_CONTENT = 'Hello from permission test';
|
||||
|
||||
function buildWriteCommand(filePath: string): string {
|
||||
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
|
||||
}
|
||||
|
||||
test.describe('permission prompts', () => {
|
||||
test('allow permission creates the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await allowPermission(page);
|
||||
|
||||
// Wait for file to be created
|
||||
await expect
|
||||
.poll(() => existsSync(filePath), {
|
||||
message: `File ${filePath} should exist after allowing permission`,
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// After allowing, the file should be created successfully
|
||||
// The tool call count might still be 1 if the UI updates quickly
|
||||
const fileContent = await readFile(filePath, 'utf-8');
|
||||
expect(fileContent.trim()).toBe(FILE_CONTENT);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('deny permission does not create the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await denyPermission(page);
|
||||
await waitForAgentFinishUI(page, 30000);
|
||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
|
||||
|
||||
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
|
||||
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
|
||||
const promptText = `Test prompt that should be preserved ${Date.now()}`;
|
||||
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, nonExistentDir);
|
||||
|
||||
// Enter prompt text
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(promptText);
|
||||
|
||||
// Try to submit - this should fail with an error about the directory not existing
|
||||
await input.press('Enter');
|
||||
|
||||
// Verify error message is displayed (error includes the path)
|
||||
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
|
||||
|
||||
// Verify the prompt text is still preserved in the input
|
||||
await expect(input).toHaveValue(promptText);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('connects via relay when direct endpoints fail', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const host = {
|
||||
serverId,
|
||||
label: 'relay-daemon',
|
||||
connections: [
|
||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
||||
],
|
||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
}, host);
|
||||
await page.reload();
|
||||
|
||||
// Should eventually connect through the relay connection.
|
||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const host = {
|
||||
serverId,
|
||||
label: 'relay-daemon',
|
||||
connections: [
|
||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
||||
],
|
||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Use relay by making the direct endpoint intentionally fail.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
}, host);
|
||||
await page.reload();
|
||||
|
||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
|
||||
const page2 = await page.context().newPage();
|
||||
await page2.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
||||
});
|
||||
await page2.goto('/');
|
||||
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
|
||||
await settingsButton2.click();
|
||||
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Stability window: keep both tabs open and ensure they remain online.
|
||||
await page.waitForTimeout(30_000);
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible();
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
||||
const repoA = await createTempGitRepo();
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await createAgent(page, 'Agent A: respond with exactly A');
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
|
||||
// Click sidebar New Agent and assert it re-opens the host draft route while
|
||||
// preserving working directory context from the selected agent.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
const searchWorkingDir = await page.evaluate(() => {
|
||||
try {
|
||||
return new URL(window.location.href).searchParams.get('workingDir');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const normalizedCandidates = new Set<string>([repoA.path]);
|
||||
if (repoA.path.startsWith('/var/')) {
|
||||
normalizedCandidates.add(`/private${repoA.path}`);
|
||||
}
|
||||
if (repoA.path.startsWith('/private/var/')) {
|
||||
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
|
||||
}
|
||||
expect(searchWorkingDir).not.toBeNull();
|
||||
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const trigger = page.getByText("Project", { exact: true }).first();
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__projectFilterFlashProbe = new Promise<{
|
||||
targetFound: boolean;
|
||||
visibleAtOrigin: boolean;
|
||||
records: Array<{ left: number; top: number; opacity: number }>;
|
||||
}>((resolve) => {
|
||||
let target: HTMLElement | null = null;
|
||||
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
||||
|
||||
const capture = () => {
|
||||
if (!target) return;
|
||||
const style = getComputedStyle(target);
|
||||
records.push({
|
||||
left: Number.parseFloat(style.left || "0"),
|
||||
top: Number.parseFloat(style.top || "0"),
|
||||
opacity: Number.parseFloat(style.opacity || "1"),
|
||||
});
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
||||
target = element;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
observer.disconnect();
|
||||
if (!target) {
|
||||
resolve({
|
||||
targetFound: false,
|
||||
visibleAtOrigin: false,
|
||||
records: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleAtOrigin = records.some(
|
||||
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
||||
);
|
||||
|
||||
resolve({
|
||||
targetFound: true,
|
||||
visibleAtOrigin,
|
||||
records,
|
||||
});
|
||||
};
|
||||
|
||||
const sampleFrames = () => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
finish();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of Array.from(mutation.addedNodes)) {
|
||||
if (!(added instanceof HTMLElement)) continue;
|
||||
if (tryResolveTarget(added)) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.target instanceof HTMLElement &&
|
||||
!target &&
|
||||
tryResolveTarget(mutation.target)
|
||||
) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
|
||||
setTimeout(() => finish(), 2500);
|
||||
});
|
||||
});
|
||||
|
||||
await trigger.click();
|
||||
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
||||
|
||||
expect(probe.targetFound).toBe(true);
|
||||
expect(probe.visibleAtOrigin).toBe(false);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
const menuButton = page.getByRole('button', { name: /menu/i }).first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
|
||||
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
|
||||
await menuButton.focus();
|
||||
|
||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Toggle sidebar');
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
||||
await page.waitForTimeout(250);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Tooltip should also appear on hover.
|
||||
await menuButton.blur();
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await menuButton.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
const triggerBox = await menuButton.boundingBox();
|
||||
const tooltipBox = await tooltip.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(tooltipBox).not.toBeNull();
|
||||
if (!triggerBox || !tooltipBox) return;
|
||||
|
||||
// side=right => tooltip starts to the right of the trigger.
|
||||
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
|
||||
// Keep it reasonably close (should be ~trigger.right + offset).
|
||||
const expectedX = triggerBox.x + triggerBox.width + 8;
|
||||
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
|
||||
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
|
||||
expect(tooltipBox.width).toBeLessThanOrEqual(500);
|
||||
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
|
||||
expect(tooltipBox.height).toBeLessThanOrEqual(80);
|
||||
|
||||
// align=center => centers should be roughly aligned (allow some clamping tolerance).
|
||||
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
|
||||
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
|
||||
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const rank = Math.ceil((p / 100) * sorted.length);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function summarize(values: number[]) {
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
avgMs: 0,
|
||||
p95Ms: 0,
|
||||
p99Ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sum = values.reduce((acc, value) => acc + value, 0);
|
||||
return {
|
||||
count: values.length,
|
||||
minMs: Math.min(...values),
|
||||
maxMs: Math.max(...values),
|
||||
avgMs: Math.round((sum / values.length) * 100) / 100,
|
||||
p95Ms: percentile(values, 95),
|
||||
p99Ms: percentile(values, 99),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStressCommand(doneMarker: string): string {
|
||||
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
|
||||
const markerFile = ".paseo-terminal-benchmark-marker";
|
||||
return [
|
||||
"i=1",
|
||||
"printf '\\033[?1049h\\033[2J'",
|
||||
"while [ $i -le 240 ]; do",
|
||||
"printf '\\033[H'",
|
||||
"r=1",
|
||||
"while [ $r -le 24 ]; do",
|
||||
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
|
||||
"r=$((r+1))",
|
||||
"done",
|
||||
"sleep 0.01",
|
||||
"i=$((i+1))",
|
||||
"done",
|
||||
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
|
||||
`printf '${doneMarker}\\n' > '${markerFile}'`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
|
||||
try {
|
||||
const text = await readFile(filePath, "utf8");
|
||||
return text.includes(marker);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
const expected = currentlyExpanded ? "false" : "true";
|
||||
|
||||
const start = Date.now();
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
|
||||
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
|
||||
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
|
||||
await newTerminalButton.click();
|
||||
|
||||
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
|
||||
await expect(surface).toBeVisible({ timeout: 60_000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const monitor = {
|
||||
samples: [] as number[],
|
||||
active: true,
|
||||
rafId: 0,
|
||||
lastTs: performance.now(),
|
||||
};
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!monitor.active) {
|
||||
return;
|
||||
}
|
||||
monitor.samples.push(ts - monitor.lastTs);
|
||||
monitor.lastTs = ts;
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
|
||||
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
|
||||
{
|
||||
stop: () => {
|
||||
if (!monitor.active) {
|
||||
return { samples: monitor.samples };
|
||||
}
|
||||
monitor.active = false;
|
||||
cancelAnimationFrame(monitor.rafId);
|
||||
return { samples: monitor.samples };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
|
||||
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
|
||||
const stressCommand = buildStressCommand(doneMarker);
|
||||
await page.keyboard.type(stressCommand, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const interactionLatenciesMs: number[] = [];
|
||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
||||
const latency = await toggleExplorerAndMeasureLatency(page);
|
||||
interactionLatenciesMs.push(latency);
|
||||
}
|
||||
|
||||
const rafResult = await page.evaluate(() => {
|
||||
const handle = (
|
||||
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
|
||||
).__PASEO_E2E_RAF_MONITOR__;
|
||||
if (!handle || typeof handle.stop !== "function") {
|
||||
return { samples: [] as number[] };
|
||||
}
|
||||
return handle.stop();
|
||||
});
|
||||
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
|
||||
timeout: 120_000,
|
||||
}).toBe(true);
|
||||
|
||||
const frameGapsMs = (rafResult.samples ?? []).filter(
|
||||
(sample) => Number.isFinite(sample) && sample > 0
|
||||
);
|
||||
const report = {
|
||||
mode: "report-only",
|
||||
profile: "single-stress",
|
||||
generatedAt: new Date().toISOString(),
|
||||
workload: {
|
||||
frames: 240,
|
||||
rowsPerFrame: 24,
|
||||
frameSleepMs: 10,
|
||||
doneMarker,
|
||||
postMarker,
|
||||
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
|
||||
},
|
||||
frameGapMs: {
|
||||
...summarize(frameGapsMs),
|
||||
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
|
||||
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
|
||||
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
|
||||
},
|
||||
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
|
||||
};
|
||||
|
||||
await testInfo.attach("terminal-responsiveness-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const workingDirectorySelect = page
|
||||
.locator('[data-testid="working-directory-select"]:visible')
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
||||
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
||||
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error("Missing working-directory-select trigger.");
|
||||
}
|
||||
if (!(searchInput instanceof HTMLInputElement)) {
|
||||
throw new Error("Missing working directory search input.");
|
||||
}
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error("Missing combobox desktop container.");
|
||||
}
|
||||
|
||||
const state = {
|
||||
samples: 0,
|
||||
underTriggerSamples: 0,
|
||||
emptyWhileSearchingSamples: 0,
|
||||
minSearchDelta: Number.POSITIVE_INFINITY,
|
||||
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
||||
minContainerDelta: Number.POSITIVE_INFINITY,
|
||||
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
||||
logs: [] as Array<{
|
||||
reason: string;
|
||||
query: string;
|
||||
searchDelta: number;
|
||||
containerDelta: number;
|
||||
hasEmpty: boolean;
|
||||
containerTop: number;
|
||||
containerBottom: number;
|
||||
triggerTop: number;
|
||||
searchBottom: number;
|
||||
}>,
|
||||
};
|
||||
|
||||
const sample = (reason: string) => {
|
||||
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
||||
return;
|
||||
}
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const searchRect = searchInput.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
||||
const searchDelta = searchRect.bottom - triggerRect.top;
|
||||
const containerDelta = containerRect.bottom - triggerRect.top;
|
||||
|
||||
state.samples += 1;
|
||||
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
||||
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
||||
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
||||
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
||||
if (searchDelta > 2 || containerDelta > 2) {
|
||||
state.underTriggerSamples += 1;
|
||||
}
|
||||
if (hasEmpty) {
|
||||
state.emptyWhileSearchingSamples += 1;
|
||||
}
|
||||
if (state.logs.length < 80) {
|
||||
state.logs.push({
|
||||
reason,
|
||||
query,
|
||||
searchDelta,
|
||||
containerDelta,
|
||||
hasEmpty,
|
||||
containerTop: containerRect.top,
|
||||
containerBottom: containerRect.bottom,
|
||||
triggerTop: triggerRect.top,
|
||||
searchBottom: searchRect.bottom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(searchInput);
|
||||
|
||||
let rafId = 0;
|
||||
const loop = () => {
|
||||
sample("raf");
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
|
||||
(window as any).__paseoComboboxObserver = {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
sample("stop");
|
||||
return {
|
||||
...state,
|
||||
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
||||
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
||||
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
||||
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const queries = [
|
||||
"/tmp/paseo-stability-a",
|
||||
"/tmp/paseo-stability-ab",
|
||||
"/tmp/paseo-stability-abc",
|
||||
"/tmp/paseo-stability-longer-branch",
|
||||
"/tmp/paseo-stability-z",
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
await searchInput.fill("");
|
||||
await searchInput.type(query, { delay: 20 });
|
||||
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
||||
await expect(customOption).toBeVisible();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
||||
const debug = JSON.stringify(stats.logs.slice(-10));
|
||||
|
||||
expect(stats.samples, debug).toBeGreaterThan(20);
|
||||
expect(stats.underTriggerSamples, debug).toBe(0);
|
||||
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
||||
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs'
|
||||
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
|
||||
const seedPrompt = `seed workspace ${Date.now()}`
|
||||
const createPrompt = `workspace draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
|
||||
// Force the setup onto the global draft surface. In slow runs the app can
|
||||
// still be on a previously opened agent/workspace view where the placement
|
||||
// form is not rendered.
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await page.getByRole('button', { name: /send message/i }).first().click()
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
|
||||
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
|
||||
expect(workspaceRouteToken).toBeTruthy()
|
||||
await waitForWorkspaceTabsVisible(page)
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page)
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click()
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page)
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
|
||||
if (!draftTabTestId) {
|
||||
throw new Error('Expected a draft workspace tab to be created.')
|
||||
}
|
||||
|
||||
const draftId = draftTabTestId.replace('workspace-tab-', '')
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
|
||||
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(createPrompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page)
|
||||
expect(finalIds).toContain(draftTabTestId)
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
|
||||
const currentUrl = page.url()
|
||||
if (!workspaceRouteToken) {
|
||||
throw new Error('Expected workspace route token to be present.')
|
||||
}
|
||||
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
sampleWorkspaceTabIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function expectComposerFocused(page: Page) {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await composer.evaluate(
|
||||
(element) => document.activeElement === element
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
|
||||
const seedPrompt = `seed prompt ${Date.now()}`;
|
||||
const createPrompt = `retarget prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page);
|
||||
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
|
||||
expect(draftTabTestId).toBeTruthy();
|
||||
|
||||
const draftId = draftTabTestId!.replace("workspace-tab-", "");
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill(createPrompt);
|
||||
await input.press("Enter");
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const snapshots = await samplingPromise;
|
||||
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
|
||||
expect(maxObservedCount).toBe(withDraftIds.length);
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page);
|
||||
expect(finalIds.length).toBe(withDraftIds.length);
|
||||
expect(finalIds).toContain(draftTabTestId!);
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
|
||||
const firstPrompt = `first tab prompt ${Date.now()}`;
|
||||
const secondPrompt = `second tab prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(secondPrompt);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
const secondAgentTabTestId = withSecondAgentIds.find(
|
||||
(id) => !beforeSecondAgentIds.includes(id)
|
||||
);
|
||||
if (!secondAgentTabTestId) {
|
||||
throw new Error("Expected second agent tab to be created.");
|
||||
}
|
||||
|
||||
const firstAgentTabTestId = beforeSecondAgentIds[0];
|
||||
if (!firstAgentTabTestId) {
|
||||
throw new Error("Expected first agent tab to exist.");
|
||||
}
|
||||
|
||||
await page.getByTestId(firstAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await page.getByTestId(secondAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: workspacePath,
|
||||
prompt: `workspace header restore ${Date.now()}`,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(terminalButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
const initialTabCount = await workspaceTabs.count();
|
||||
const targetTabCount = initialTabCount + 8;
|
||||
|
||||
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
|
||||
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
|
||||
await terminalButton.click();
|
||||
await expect
|
||||
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(attempt + 1);
|
||||
}
|
||||
|
||||
const agentBoundsBefore = await agentButton.boundingBox();
|
||||
const terminalBoundsBefore = await terminalButton.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
|
||||
expect(agentBoundsBefore).not.toBeNull();
|
||||
expect(terminalBoundsBefore).not.toBeNull();
|
||||
expect(viewport).not.toBeNull();
|
||||
|
||||
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
|
||||
await tabsScroll.evaluate((el) => {
|
||||
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const agentBoundsAfter = await agentButton.boundingBox();
|
||||
const terminalBoundsAfter = await terminalButton.boundingBox();
|
||||
|
||||
expect(agentBoundsAfter).not.toBeNull();
|
||||
expect(terminalBoundsAfter).not.toBeNull();
|
||||
|
||||
if (!agentBoundsAfter || !terminalBoundsAfter) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 });
|
||||
|
||||
const lastTabBounds = await workspaceTabs.last().boundingBox();
|
||||
const agentBounds = await agentButton.boundingBox();
|
||||
|
||||
expect(lastTabBounds).not.toBeNull();
|
||||
expect(agentBounds).not.toBeNull();
|
||||
|
||||
if (!lastTabBounds || !agentBounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width);
|
||||
|
||||
expect(horizontalGap).toBeGreaterThanOrEqual(0);
|
||||
expect(horizontalGap).toBeLessThanOrEqual(24);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
if (initiallyExpanded) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { test } from './fixtures';
|
||||
import { setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import {
|
||||
expectWorkspaceHeader,
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from './helpers/workspace-ui';
|
||||
|
||||
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
|
||||
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
|
||||
|
||||
const tokenA = `SYNC_A_${Date.now()}`;
|
||||
const tokenB = `SYNC_B_${Date.now()}`;
|
||||
|
||||
try {
|
||||
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
|
||||
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await seedWorkspaceActivity(page, tokenA);
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoB.path);
|
||||
await seedWorkspaceActivity(page, tokenB);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-b-branch',
|
||||
subtitle: workspaceLabelFromPath(repoB.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
await repoB.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.30",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --prefix ../expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"android": "npm run android:development",
|
||||
"android:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
|
||||
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clean": "expo prebuild --platform android --clean --non-interactive",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
@@ -24,16 +25,16 @@
|
||||
"build": "npm run build:web",
|
||||
"build:web": "expo export --platform web",
|
||||
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.30",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.27",
|
||||
"@getpaseo/server": "0.1.30",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import "@/styles/unistyles";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
@@ -35,6 +41,7 @@ import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||
@@ -61,11 +68,13 @@ import {
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
parseServerIdFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { attachConsole } from "@/utils/tauri-attach-console";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
polyfillCrypto();
|
||||
attachConsole();
|
||||
@@ -378,6 +387,7 @@ function AppContainer({
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<KeyboardShortcutsDialog />
|
||||
@@ -469,7 +479,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
useFaviconStatus();
|
||||
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
|
||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const shouldShowAppChrome = activeServerId !== null;
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
@@ -497,6 +508,26 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationActiveWorkspaceObserver() {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
useEffect(() => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeState();
|
||||
unsubscribeReady();
|
||||
};
|
||||
}, [navigationRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function LoadingView({ message }: { message?: string } = {}) {
|
||||
return (
|
||||
<View
|
||||
@@ -553,6 +584,7 @@ export default function RootLayout() {
|
||||
<GestureHandlerRootView
|
||||
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
|
||||
>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
@@ -576,7 +608,12 @@ export default function RootLayout() {
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]"
|
||||
getId={({ params }) =>
|
||||
`${params?.serverId}:${params?.workspaceId}`
|
||||
}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||
import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from '@/utils/host-routes'
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
const expoPathname = usePathname()
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>()
|
||||
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
|
||||
const serverId = activeRoute?.serverId ?? ''
|
||||
const workspaceId = activeRoute?.workspaceId ?? ''
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[]
|
||||
workspaceId?: string | string[]
|
||||
open?: string | string[]
|
||||
}>()
|
||||
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId
|
||||
const workspaceValue = Array.isArray(params.workspaceId)
|
||||
? params.workspaceId[0]
|
||||
: params.workspaceId
|
||||
const serverId = serverValue?.trim() ?? ''
|
||||
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
|
||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
shouldRedirectToWelcome,
|
||||
shouldWaitOnStartupRace,
|
||||
WELCOME_ROUTE,
|
||||
} from './index-startup'
|
||||
} from '@/app-support/index-startup'
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
|
||||
14
packages/app/src/attachments/store.test.ts
Normal file
14
packages/app/src/attachments/store.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __setAttachmentStoreForTests, getAttachmentStore } from "./store";
|
||||
|
||||
describe("attachment store", () => {
|
||||
afterEach(() => {
|
||||
__setAttachmentStoreForTests(null);
|
||||
});
|
||||
|
||||
it("creates the default web attachment store without runtime module resolution errors", async () => {
|
||||
const store = await getAttachmentStore();
|
||||
|
||||
expect(store.storageType).toBe("web-indexeddb");
|
||||
});
|
||||
});
|
||||
@@ -7,20 +7,20 @@ let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isTauriEnvironment()) {
|
||||
const { createDesktopAttachmentStore } = require(
|
||||
"@/desktop/attachments/desktop-attachment-store"
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
return createDesktopAttachmentStore();
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = require(
|
||||
"@/attachments/web/indexeddb-attachment-store"
|
||||
const { createIndexedDbAttachmentStore } = await import(
|
||||
"./web/indexeddb-attachment-store"
|
||||
);
|
||||
return createIndexedDbAttachmentStore();
|
||||
}
|
||||
|
||||
const { createNativeFileAttachmentStore } = require(
|
||||
"@/attachments/native/native-file-attachment-store"
|
||||
const { createNativeFileAttachmentStore } = await import(
|
||||
"./native/native-file-attachment-store"
|
||||
);
|
||||
return createNativeFileAttachmentStore();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, Shield } from "lucide-react-native";
|
||||
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -25,7 +25,23 @@ import type {
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||
import { baseColors } from "@/styles/theme";
|
||||
|
||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
};
|
||||
|
||||
const MODE_COLOR_MAP: Record<string, string> = {
|
||||
default: baseColors.blue[500],
|
||||
safe: baseColors.green[500],
|
||||
moderate: baseColors.amber[500],
|
||||
dangerous: baseColors.red[500],
|
||||
readonly: baseColors.purple[500],
|
||||
};
|
||||
|
||||
type DropdownTriggerRenderProps = {
|
||||
label: string;
|
||||
@@ -563,6 +579,10 @@ export function AgentConfigRow({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||
|
||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||
|
||||
return (
|
||||
<View style={styles.agentConfigRow}>
|
||||
<View style={styles.agentConfigColumn}>
|
||||
@@ -603,7 +623,7 @@ export function AgentConfigRow({
|
||||
placeholder="Default"
|
||||
disabled={disabled || modeOptions.length === 0}
|
||||
onSelect={onSelectMode}
|
||||
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
|
||||
showLabel={false}
|
||||
testID="draft-mode-select"
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,9 @@ describe('resolveStatusControlMode', () => {
|
||||
selectedModel: '',
|
||||
onSelectModel: () => undefined,
|
||||
isModelLoading: false,
|
||||
allProviderModels: new Map(),
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: '',
|
||||
onSelectThinkingOption: () => undefined,
|
||||
|
||||
@@ -699,7 +699,7 @@ export function AgentInputArea({
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={theme.iconSize.lg} color="white" fill="white" />
|
||||
<Square size={theme.iconSize.md} color="white" fill="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -720,15 +720,16 @@ export function AgentInputArea({
|
||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||
accessibilityLabel="Enable Voice mode"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
style={({ hovered }) => [
|
||||
styles.realtimeVoiceButton as any,
|
||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
{voice?.isVoiceSwitching ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<AudioLines size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<AudioLines size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -885,8 +886,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
zIndex: 30,
|
||||
},
|
||||
cancelButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
alignItems: 'center',
|
||||
@@ -898,12 +899,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -911,6 +909,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.palette.green[600],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, Platform, Pressable } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react-native'
|
||||
import { getProviderIcon } from '@/components/provider-icons'
|
||||
import { CombinedModelSelector } from '@/components/combined-model-selector'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import {
|
||||
@@ -10,7 +20,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -18,6 +28,11 @@ import type {
|
||||
AgentProvider,
|
||||
} from '@server/server/agent/agent-sdk-types'
|
||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
||||
import {
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
} from '@server/server/agent/provider-manifest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
|
||||
|
||||
type StatusOption = {
|
||||
@@ -26,6 +41,7 @@ type StatusOption = {
|
||||
}
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string
|
||||
providerOptions?: StatusOption[]
|
||||
selectedProviderId?: string
|
||||
onSelectProvider?: (providerId: string) => void
|
||||
@@ -53,6 +69,9 @@ export interface DraftAgentStatusBarProps {
|
||||
selectedModel: string
|
||||
onSelectModel: (modelId: string) => void
|
||||
isModelLoading: boolean
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
isAllModelsLoading: boolean
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
|
||||
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
|
||||
selectedThinkingOptionId: string
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void
|
||||
@@ -72,7 +91,36 @@ function findOptionLabel(options: StatusOption[] | undefined, selectedId: string
|
||||
return selected?.label ?? fallback
|
||||
}
|
||||
|
||||
const MODE_ICONS = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
} as const
|
||||
|
||||
|
||||
function getModeIconColor(
|
||||
colorTier: AgentModeColorTier | undefined,
|
||||
palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } }
|
||||
): string {
|
||||
switch (colorTier) {
|
||||
case 'default':
|
||||
return palette.blue[500]
|
||||
case 'safe':
|
||||
return palette.green[500]
|
||||
case 'moderate':
|
||||
return palette.amber[500]
|
||||
case 'dangerous':
|
||||
return palette.red[500]
|
||||
case 'readonly':
|
||||
return palette.purple[500]
|
||||
default:
|
||||
return palette.blue[500]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function ControlledStatusBar({
|
||||
provider,
|
||||
providerOptions,
|
||||
selectedProviderId,
|
||||
onSelectProvider,
|
||||
@@ -113,6 +161,11 @@ function ControlledStatusBar({
|
||||
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
|
||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
|
||||
|
||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
|
||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
|
||||
const ProviderIcon = getProviderIcon(provider)
|
||||
|
||||
const hasAnyControl =
|
||||
Boolean(providerOptions?.length) ||
|
||||
Boolean(modeOptions?.length) ||
|
||||
@@ -144,6 +197,23 @@ function ControlledStatusBar({
|
||||
[thinkingOptions]
|
||||
)
|
||||
|
||||
const renderModeOption = useCallback(
|
||||
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
|
||||
const visuals = getModeVisuals(provider, option.id)
|
||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[provider, theme.colors.foreground]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
|
||||
setOpenSelector(nextOpen ? selector : null)
|
||||
@@ -152,7 +222,7 @@ function ControlledStatusBar({
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
<>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
@@ -196,17 +266,20 @@ function ControlledStatusBar({
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
styles.modeIconBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent mode"
|
||||
accessibilityLabel={`Select agent mode (${displayMode})`}
|
||||
testID="agent-mode-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : (
|
||||
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModeOptions}
|
||||
@@ -217,38 +290,44 @@ function ControlledStatusBar({
|
||||
onOpenChange={handleOpenChange('mode')}
|
||||
anchorRef={modeAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
renderOption={renderModeOption}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
{canSelectModel ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<>
|
||||
@@ -264,14 +343,10 @@ function ControlledStatusBar({
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select thinking option"
|
||||
accessibilityLabel={`Select thinking option (${displayThinking})`}
|
||||
testID="agent-thinking-selector"
|
||||
>
|
||||
<Brain
|
||||
size={theme.iconSize.xs}
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={{ marginTop: 1 }}
|
||||
/>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
@@ -300,7 +375,7 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Agent preferences"
|
||||
testID="agent-preferences-button"
|
||||
>
|
||||
<SlidersHorizontal size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<SlidersHorizontal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
@@ -355,17 +430,57 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Select agent mode"
|
||||
testID="agent-preferences-mode"
|
||||
>
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : null}
|
||||
<Text style={styles.sheetSelectText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{modeOptions.map((mode) => (
|
||||
{modeOptions.map((mode) => {
|
||||
const visuals = getModeVisuals(provider, mode.id)
|
||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
leading={<Icon size={16} color={theme.colors.foreground} />}
|
||||
>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canSelectModel ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{mode.label}
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -373,36 +488,6 @@ function ControlledStatusBar({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
@@ -504,6 +589,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={agent.provider}
|
||||
modeOptions={
|
||||
modeOptions.length > 0
|
||||
? modeOptions
|
||||
@@ -555,33 +641,26 @@ export function DraftAgentStatusBar({
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const providerOptions = useMemo<StatusOption[]>(() => {
|
||||
return providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
}))
|
||||
}, [providerDefinitions])
|
||||
const isWeb = Platform.OS === 'web'
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
if (modeOptions.length === 0) {
|
||||
return [{ id: '', label: 'Default' }]
|
||||
}
|
||||
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
|
||||
return modeOptions.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
}))
|
||||
}, [modeOptions])
|
||||
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
const options: StatusOption[] = [{ id: '', label: 'Auto' }]
|
||||
for (const model of models) {
|
||||
options.push({ id: model.id, label: model.label })
|
||||
}
|
||||
return options
|
||||
}, [models])
|
||||
|
||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
|
||||
}, [thinkingOptions])
|
||||
@@ -590,8 +669,45 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
|
||||
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
isLoading={isAllModelsLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const providerOptions = providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
}))
|
||||
|
||||
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
|
||||
for (const model of models) {
|
||||
modelOptions.push({ id: model.id, label: model.label })
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerOptions={providerOptions}
|
||||
selectedProviderId={selectedProvider}
|
||||
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
|
||||
@@ -613,18 +729,26 @@ export function DraftAgentStatusBar({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-end',
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
modeBadge: {
|
||||
height: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
modeIconBadge: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
modeBadgeHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
@@ -640,8 +764,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
prefsButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -22,11 +22,10 @@ import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
@@ -69,6 +68,12 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import {
|
||||
getWorkingIndicatorDotStrength,
|
||||
WORKING_INDICATOR_CYCLE_MS,
|
||||
WORKING_INDICATOR_OFFSETS,
|
||||
} from "@/utils/working-indicator";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -155,7 +160,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeInlinePath(target.path, agent.cwd);
|
||||
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -314,6 +319,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -656,147 +662,59 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
});
|
||||
|
||||
function normalizeInlinePath(
|
||||
rawPath: string,
|
||||
cwd?: string
|
||||
): { directory: string; file?: string } | null {
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedInput = normalizePathInput(rawPath);
|
||||
if (!normalizedInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = normalizedInput;
|
||||
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
|
||||
if (cwdRelative) {
|
||||
normalized = cwdRelative;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.slice(2) || ".";
|
||||
}
|
||||
|
||||
if (!normalized.length) {
|
||||
normalized = ".";
|
||||
}
|
||||
|
||||
if (normalized === ".") {
|
||||
return { directory: "." };
|
||||
}
|
||||
|
||||
if (normalized.endsWith("/")) {
|
||||
const dir = normalized.replace(/\/+$/, "");
|
||||
return { directory: dir.length > 0 ? dir : "." };
|
||||
}
|
||||
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
||||
|
||||
return {
|
||||
directory: directory.length > 0 ? directory : ".",
|
||||
file: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathInput(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^['"`]/, "")
|
||||
.replace(/['"`]$/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
||||
const normalizedCwd = normalizePathInput(cwd);
|
||||
if (
|
||||
!normalizedCwd ||
|
||||
!isAbsolutePath(pathValue) ||
|
||||
!isAbsolutePath(normalizedCwd)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
|
||||
const comparePath = normalizePathForCompare(pathValue);
|
||||
const compareCwd = normalizePathForCompare(normalizedCwdBase);
|
||||
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
|
||||
const comparePrefix = normalizePathForCompare(prefix);
|
||||
|
||||
if (comparePath === compareCwd) {
|
||||
return ".";
|
||||
}
|
||||
|
||||
if (comparePath.startsWith(comparePrefix)) {
|
||||
return pathValue.slice(prefix.length) || ".";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value: string): string {
|
||||
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function isAbsolutePath(value: string): boolean {
|
||||
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
||||
}
|
||||
|
||||
function WorkingIndicator() {
|
||||
const dotOne = useSharedValue(0);
|
||||
const dotTwo = useSharedValue(0);
|
||||
const dotThree = useSharedValue(0);
|
||||
const bounceDuration = 600;
|
||||
const bounceDelayOffset = 160;
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
const sharedValues = [dotOne, dotTwo, dotThree];
|
||||
sharedValues.forEach((value, index) => {
|
||||
value.value = withDelay(
|
||||
index * bounceDelayOffset,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: bounceDuration }),
|
||||
withTiming(0, { duration: bounceDuration })
|
||||
),
|
||||
-1
|
||||
)
|
||||
);
|
||||
});
|
||||
progress.value = 0;
|
||||
progress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: WORKING_INDICATOR_CYCLE_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
|
||||
return () => {
|
||||
sharedValues.forEach((value) => {
|
||||
cancelAnimation(value);
|
||||
value.value = 0;
|
||||
});
|
||||
cancelAnimation(progress);
|
||||
progress.value = 0;
|
||||
};
|
||||
}, [dotOne, dotTwo, dotThree]);
|
||||
}, [progress]);
|
||||
|
||||
const translateDistance = -2;
|
||||
const dotOneStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotOne.value * 0.7,
|
||||
transform: [{ translateY: dotOne.value * translateDistance }],
|
||||
}));
|
||||
const dotOneStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(
|
||||
progress.value,
|
||||
WORKING_INDICATOR_OFFSETS[0]
|
||||
);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotTwoStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotTwo.value * 0.7,
|
||||
transform: [{ translateY: dotTwo.value * translateDistance }],
|
||||
}));
|
||||
const dotTwoStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(
|
||||
progress.value,
|
||||
WORKING_INDICATOR_OFFSETS[1]
|
||||
);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotThreeStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotThree.value * 0.7,
|
||||
transform: [{ translateY: dotThree.value * translateDistance }],
|
||||
}));
|
||||
const dotThreeStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(
|
||||
progress.value,
|
||||
WORKING_INDICATOR_OFFSETS[2]
|
||||
);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={stylesheet.workingIndicatorBubble}>
|
||||
|
||||
372
packages/app/src/components/combined-model-selector.tsx
Normal file
372
packages/app/src/components/combined-model-selector.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, Pressable, Platform } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
|
||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
||||
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
|
||||
import { getProviderIcon } from '@/components/provider-icons'
|
||||
|
||||
const INLINE_MODEL_THRESHOLD = 8
|
||||
|
||||
type DrillDownView = { provider: string }
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void
|
||||
isLoading: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function CombinedModelSelector({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const anchorRef = useRef<View>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [view, setView] = useState<'groups' | DrillDownView>('groups')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (open) {
|
||||
const models = allProviderModels.get(selectedProvider)
|
||||
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
||||
setView({ provider: selectedProvider })
|
||||
}
|
||||
} else {
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
}
|
||||
}, [allProviderModels, selectedProvider])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId)
|
||||
setIsOpen(false)
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
},
|
||||
[onSelect]
|
||||
)
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider)
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
const models = allProviderModels.get(selectedProvider)
|
||||
if (!models) return isLoading ? 'Loading...' : 'Auto'
|
||||
const model = models.find((m) => m.id === selectedModel)
|
||||
return model?.label ?? 'Auto'
|
||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={() => handleOpenChange(!isOpen)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.trigger,
|
||||
hovered && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={[]}
|
||||
value=""
|
||||
onSelect={() => {}}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
title="Select model"
|
||||
>
|
||||
{view === 'groups' ? (
|
||||
<GroupsView
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={handleSelect}
|
||||
onDrillDown={(provider) => {
|
||||
setView({ provider })
|
||||
setSearchQuery('')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DrillDownModelView
|
||||
provider={view.provider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
models={allProviderModels.get(view.provider) ?? []}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={handleSelect}
|
||||
onBack={() => {
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Combobox>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsView({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
onSelect: (provider: string, modelId: string) => void
|
||||
onDrillDown: (provider: string) => void
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
|
||||
return (
|
||||
<View>
|
||||
{providerDefinitions.map((def, index) => {
|
||||
const models = allProviderModels.get(def.id) ?? []
|
||||
const isInline = models.length <= INLINE_MODEL_THRESHOLD
|
||||
const ProvIcon = getProviderIcon(def.id)
|
||||
|
||||
return (
|
||||
<View key={def.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
|
||||
{isInline ? (
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sectionHeadingText}>{def.label}</Text>
|
||||
</View>
|
||||
{models.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
selected={model.id === selectedModel && def.id === selectedProvider}
|
||||
onPress={() => onSelect(def.id, model.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => onDrillDown(def.id)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.drillDownRow,
|
||||
hovered && styles.drillDownRowHovered,
|
||||
pressed && styles.drillDownRowPressed,
|
||||
]}
|
||||
>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{def.label}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>{models.length}</Text>
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DrillDownModelView({
|
||||
provider,
|
||||
providerDefinitions,
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
provider: string
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
models: AgentModelDefinition[]
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
onSelect: (provider: string, modelId: string) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
const ProvIcon = getProviderIcon(provider)
|
||||
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return models
|
||||
const q = searchQuery.toLowerCase()
|
||||
return models.filter(
|
||||
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
|
||||
)
|
||||
}, [models, searchQuery])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.backButton,
|
||||
hovered && styles.backButtonHovered,
|
||||
pressed && styles.backButtonPressed,
|
||||
]}
|
||||
>
|
||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
|
||||
<SearchInput
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
autoFocus={Platform.OS === 'web'}
|
||||
/>
|
||||
|
||||
{filteredModels.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
description={model.description}
|
||||
selected={model.id === selectedModel && provider === selectedProvider}
|
||||
onPress={() => onSelect(provider, model.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredModels.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[1],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
sectionHeadingText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
drillDownRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
},
|
||||
drillDownRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
drillDownRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
drillDownText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
drillDownTrailing: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
drillDownCount: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
backButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}))
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { memo, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||
import { Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
@@ -69,6 +69,9 @@ export function CommandCenter() {
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") {
|
||||
return;
|
||||
@@ -99,18 +102,12 @@ export function CommandCenter() {
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
}, [activeIndex]);
|
||||
}, [activeIndex, open]);
|
||||
|
||||
if (Platform.OS !== "web") return null;
|
||||
if (Platform.OS !== "web" || !open) return null;
|
||||
|
||||
const actionItems = useMemo(
|
||||
() => items.filter((item) => item.kind === "action"),
|
||||
[items]
|
||||
);
|
||||
const agentItems = useMemo(
|
||||
() => items.filter((item) => item.kind === "agent"),
|
||||
[items]
|
||||
);
|
||||
const actionItems = items.filter((item) => item.kind === "action");
|
||||
const agentItems = items.filter((item) => item.kind === "agent");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -69,7 +69,6 @@ export function DictationControls({
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isDetecting
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
@@ -177,7 +176,6 @@ export function DictationOverlay({
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isDetecting
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.palette.white}
|
||||
|
||||
@@ -479,7 +479,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -23,6 +23,7 @@ import Animated, {
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
@@ -581,13 +582,12 @@ export function FileExplorerPane({
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
@@ -875,7 +875,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
height: 32 + theme.spacing[2] * 2,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
@@ -898,7 +898,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
sortButton: {
|
||||
height: 32,
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1025,8 +1025,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
iconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1034,10 +1034,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonPressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.96 }],
|
||||
},
|
||||
refreshIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
describe("git-action-visibility", () => {
|
||||
describe("shouldShowMergeFromBaseAction", () => {
|
||||
it("shows on non-base branches", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: false,
|
||||
hasRemote: false,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides on base branch when no remote exists", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: false,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides on base branch when local is in sync with origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows on base branch when ahead of origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 1,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows on base branch when behind origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 2,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
export function shouldShowMergeFromBaseAction(input: {
|
||||
isOnBaseBranch: boolean;
|
||||
hasRemote: boolean;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
}): boolean {
|
||||
if (!input.isOnBaseBranch) {
|
||||
return true;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return false;
|
||||
}
|
||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||
}
|
||||
149
packages/app/src/components/git-actions-policy.test.ts
Normal file
149
packages/app/src/components/git-actions-policy.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
|
||||
|
||||
function createInput(
|
||||
overrides: Partial<BuildGitActionsInput> = {}
|
||||
): BuildGitActionsInput {
|
||||
return {
|
||||
isGit: true,
|
||||
githubFeaturesEnabled: true,
|
||||
hasPullRequest: false,
|
||||
pullRequestUrl: null,
|
||||
hasRemote: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isOnBaseBranch: true,
|
||||
hasUncommittedChanges: false,
|
||||
baseRefAvailable: true,
|
||||
baseRefLabel: "main",
|
||||
aheadCount: 0,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
shouldPromoteArchive: false,
|
||||
shipDefault: "merge",
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
push: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
pr: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("git-actions-policy", () => {
|
||||
it("keeps the secondary menu order stable while the primary action changes", () => {
|
||||
const noPrActions = buildGitActions(createInput());
|
||||
const withPrActions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/123",
|
||||
aheadCount: 3,
|
||||
aheadOfOrigin: 2,
|
||||
shipDefault: "pr",
|
||||
})
|
||||
);
|
||||
|
||||
expect(noPrActions.primary).toBeNull();
|
||||
expect(withPrActions.primary?.id).toBe("push");
|
||||
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
]);
|
||||
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables hidden-before actions with explanations instead", () => {
|
||||
const actions = buildGitActions(createInput());
|
||||
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
|
||||
|
||||
expect(actionById.get("push")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No remote configured",
|
||||
});
|
||||
expect(actionById.get("pr")).toMatchObject({
|
||||
label: "Create PR",
|
||||
disabled: true,
|
||||
description: "Branch has no commits ahead of main",
|
||||
});
|
||||
expect(actionById.get("merge-branch")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No commits to merge into main",
|
||||
});
|
||||
expect(actionById.get("merge-from-base")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No remote configured",
|
||||
});
|
||||
expect(actionById.has("archive-worktree")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the current primary action visible in the menu", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
})
|
||||
);
|
||||
|
||||
expect(actions.primary?.id).toBe("pr");
|
||||
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true);
|
||||
});
|
||||
|
||||
it("disables sync on the base branch when already up to date", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
})
|
||||
);
|
||||
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||
|
||||
expect(syncAction).toMatchObject({
|
||||
label: "Sync",
|
||||
disabled: true,
|
||||
description: "Already up to date",
|
||||
});
|
||||
});
|
||||
|
||||
it("only shows archive worktree for paseo worktrees", () => {
|
||||
const hidden = buildGitActions(createInput());
|
||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
|
||||
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
|
||||
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
|
||||
});
|
||||
});
|
||||
256
packages/app/src/components/git-actions-policy.ts
Normal file
256
packages/app/src/components/git-actions-policy.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
| "push"
|
||||
| "pr"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
|
||||
export interface GitAction {
|
||||
id: GitActionId;
|
||||
label: string;
|
||||
pendingLabel: string;
|
||||
successLabel: string;
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
description?: string;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export interface GitActions {
|
||||
primary: GitAction | null;
|
||||
secondary: GitAction[];
|
||||
menu: GitAction[];
|
||||
}
|
||||
|
||||
interface GitActionRuntimeState {
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export interface BuildGitActionsInput {
|
||||
isGit: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
hasPullRequest: boolean;
|
||||
pullRequestUrl: string | null;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
baseRefAvailable: boolean;
|
||||
baseRefLabel: string;
|
||||
aheadCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
shouldPromoteArchive: boolean;
|
||||
shipDefault: "merge" | "pr";
|
||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||
}
|
||||
|
||||
const SECONDARY_ACTION_IDS: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
];
|
||||
|
||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
allActions.set("commit", {
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: input.runtime.commit.disabled,
|
||||
status: input.runtime.commit.status,
|
||||
icon: input.runtime.commit.icon,
|
||||
handler: input.runtime.commit.handler,
|
||||
});
|
||||
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: input.runtime.push.disabled || !input.hasRemote,
|
||||
status: input.runtime.push.status,
|
||||
description: input.hasRemote ? undefined : "No remote configured",
|
||||
icon: input.runtime.push.icon,
|
||||
handler: input.runtime.push.handler,
|
||||
});
|
||||
|
||||
allActions.set("pr", buildPrAction(input));
|
||||
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: `Merge into ${input.baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled:
|
||||
input.runtime["merge-branch"].disabled ||
|
||||
!input.baseRefAvailable ||
|
||||
input.hasUncommittedChanges ||
|
||||
input.aheadCount === 0,
|
||||
status: input.runtime["merge-branch"].status,
|
||||
description: getMergeBranchDescription(input),
|
||||
icon: input.runtime["merge-branch"].icon,
|
||||
handler: input.runtime["merge-branch"].handler,
|
||||
});
|
||||
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
|
||||
status: input.runtime["merge-from-base"].status,
|
||||
description: getMergeFromBaseDescription(input),
|
||||
icon: input.runtime["merge-from-base"].icon,
|
||||
handler: input.runtime["merge-from-base"].handler,
|
||||
});
|
||||
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
|
||||
status: input.runtime["archive-worktree"].status,
|
||||
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
|
||||
icon: input.runtime["archive-worktree"].icon,
|
||||
handler: input.runtime["archive-worktree"].handler,
|
||||
});
|
||||
|
||||
const primaryActionId = getPrimaryActionId(input);
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondary.push(allActions.get("archive-worktree")!);
|
||||
}
|
||||
|
||||
return { primary, secondary, menu: [] };
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
|
||||
return "archive-worktree";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "commit";
|
||||
}
|
||||
if (input.aheadOfOrigin > 0 && input.hasRemote) {
|
||||
return "push";
|
||||
}
|
||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||
return "pr";
|
||||
}
|
||||
if (
|
||||
input.isOnBaseBranch &&
|
||||
input.hasRemote &&
|
||||
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
|
||||
) {
|
||||
return "merge-from-base";
|
||||
}
|
||||
if (input.aheadCount > 0) {
|
||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
if (input.hasPullRequest && input.pullRequestUrl) {
|
||||
return {
|
||||
id: "pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
|
||||
status: input.runtime.pr.status,
|
||||
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
|
||||
icon: input.runtime.pr.icon,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "pr",
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled:
|
||||
input.runtime.pr.disabled ||
|
||||
!input.githubFeaturesEnabled ||
|
||||
input.aheadCount === 0,
|
||||
status: input.runtime.pr.status,
|
||||
description: getCreatePrDescription(input),
|
||||
icon: input.runtime.pr.icon,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isOnBaseBranch) {
|
||||
return true;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return false;
|
||||
}
|
||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||
}
|
||||
|
||||
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.githubFeaturesEnabled) {
|
||||
return "GitHub features unavailable";
|
||||
}
|
||||
if (input.aheadCount === 0) {
|
||||
return `Branch has no commits ahead of ${input.baseRefLabel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.baseRefAvailable) {
|
||||
return "Base ref unavailable";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Requires clean working tree";
|
||||
}
|
||||
if (input.aheadCount === 0) {
|
||||
return `No commits to merge into ${input.baseRefLabel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.baseRefAvailable) {
|
||||
return "Base ref unavailable";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Requires clean working tree";
|
||||
}
|
||||
if (!input.isOnBaseBranch) {
|
||||
return undefined;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return "No remote configured";
|
||||
}
|
||||
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
|
||||
return "Already up to date";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
@@ -79,7 +79,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
|
||||
closeOnSelect={
|
||||
action.status === "idle" &&
|
||||
action.id === "pr" &&
|
||||
action.label === "View PR"
|
||||
}
|
||||
description={action.description}
|
||||
onSelect={action.handler}
|
||||
>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import {
|
||||
@@ -47,19 +48,19 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
buildGitActions,
|
||||
type GitActions,
|
||||
} from "@/components/git-actions-policy";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
|
||||
// Re-export types from shared hook
|
||||
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
@@ -206,7 +207,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
<View
|
||||
style={[
|
||||
styles.fileSectionHeaderContainer,
|
||||
!isExpanded && styles.fileSectionBorder,
|
||||
isExpanded && styles.fileSectionHeaderExpanded,
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
layoutYRef.current = event.nativeEvent.layout.y;
|
||||
@@ -790,19 +791,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
actionsDisabled || mergeStatus === "pending";
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled ||
|
||||
mergeFromBaseStatus === "pending" ||
|
||||
hasUncommittedChanges ||
|
||||
!baseRef ||
|
||||
(isOnBaseBranch && !hasRemote);
|
||||
actionsDisabled || mergeFromBaseStatus === "pending";
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
actionsDisabled || pushStatus === "pending";
|
||||
const archiveDisabled =
|
||||
actionsDisabled ||
|
||||
archiveStatus === "pending" ||
|
||||
!gitStatus?.isPaseoOwnedWorktree;
|
||||
actionsDisabled || archiveStatus === "pending";
|
||||
|
||||
let bodyContent: ReactElement;
|
||||
|
||||
@@ -892,184 +887,67 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
// ==========================================================================
|
||||
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
if (!isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
// Build all possible actions
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
// Commit - always available
|
||||
allActions.set("commit", {
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCommit,
|
||||
return buildGitActions({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
hasPullRequest,
|
||||
pullRequestUrl: prStatus?.url ?? null,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
aheadCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shouldPromoteArchive,
|
||||
shipDefault,
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCommit,
|
||||
},
|
||||
push: {
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handlePush,
|
||||
},
|
||||
pr: {
|
||||
disabled: prDisabled,
|
||||
status: hasPullRequest ? "idle" : prCreateStatus,
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: () => {
|
||||
if (prStatus?.url) {
|
||||
openURLInNewTab(prStatus.url);
|
||||
return;
|
||||
}
|
||||
handleCreatePr();
|
||||
},
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeBranch,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Push - when has remote
|
||||
if (hasRemote) {
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
description: !hasRemote ? "No remote configured" : undefined,
|
||||
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handlePush,
|
||||
});
|
||||
}
|
||||
|
||||
// View PR - when PR exists
|
||||
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||
const prUrl = prStatus.url;
|
||||
allActions.set("view-pr", {
|
||||
id: "view-pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: () => openURLInNewTab(prUrl),
|
||||
});
|
||||
}
|
||||
|
||||
// Create PR - when ahead of base and no PR
|
||||
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||
allActions.set("create-pr", {
|
||||
id: "create-pr",
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled: prDisabled,
|
||||
status: prCreateStatus,
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCreatePr,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge branch - when ahead of base
|
||||
if (aheadCount > 0) {
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: `Merge into ${baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// Update/sync from base
|
||||
if (
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch,
|
||||
hasRemote,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
})
|
||||
) {
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
description:
|
||||
hasUncommittedChanges
|
||||
? "Requires clean working tree"
|
||||
: isOnBaseBranch && !hasRemote
|
||||
? "No remote configured"
|
||||
: undefined,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
}
|
||||
|
||||
// Archive worktree - only for Paseo worktrees
|
||||
if (isPaseoOwnedWorktree) {
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleArchiveWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
// Select primary action (priority rules)
|
||||
let primaryActionId: GitActionId | null = null;
|
||||
|
||||
// Rule 0: Post-ship in worktree -> Archive
|
||||
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||
primaryActionId = "archive-worktree";
|
||||
}
|
||||
// Rule 1: Uncommitted changes → Commit
|
||||
else if (hasUncommittedChanges) {
|
||||
primaryActionId = "commit";
|
||||
}
|
||||
// Rule 2: Ahead of origin → Push
|
||||
else if (aheadOfOrigin > 0 && allActions.has("push")) {
|
||||
primaryActionId = "push";
|
||||
}
|
||||
// Rule 3: Has PR → View PR
|
||||
else if (hasPullRequest) {
|
||||
primaryActionId = "view-pr";
|
||||
}
|
||||
// Rule 4: On base branch -> surface sync explicitly
|
||||
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||
primaryActionId = "merge-from-base";
|
||||
}
|
||||
// Rule 5: Ahead of base → Ship action based on preference
|
||||
else if (aheadCount > 0) {
|
||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||
|
||||
const preferredAction = allActions.get(preferred);
|
||||
const fallbackAction = allActions.get(fallback);
|
||||
|
||||
if (preferredAction && !preferredAction.disabled) {
|
||||
primaryActionId = preferred;
|
||||
} else if (fallbackAction && !fallbackAction.disabled) {
|
||||
primaryActionId = fallback;
|
||||
} else if (preferredAction) {
|
||||
primaryActionId = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
|
||||
// Secondary actions: ship-related + merge from base + push (excluding primary)
|
||||
const secondaryIds: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"create-pr",
|
||||
"view-pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
"archive-worktree",
|
||||
];
|
||||
const secondary = secondaryIds
|
||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||
.map(id => allActions.get(id)!);
|
||||
|
||||
// Menu actions: none for now (all actionable items are in primary/secondary)
|
||||
const menu: GitAction[] = [];
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
@@ -1209,11 +1087,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
},
|
||||
diffStatusContainer: {
|
||||
paddingVertical: 1.5,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
diffStatusInner: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
@@ -1313,6 +1192,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
fileSectionHeaderContainer: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
fileSectionHeaderExpanded: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
fileSectionBodyContainer: {
|
||||
@@ -1331,7 +1212,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
zIndex: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
|
||||
@@ -3,11 +3,13 @@ import Svg, { Rect, Line } from "react-native-svg";
|
||||
interface SourceControlPanelIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function SourceControlPanelIcon({
|
||||
size = 16,
|
||||
color = "currentColor",
|
||||
strokeWidth = 2,
|
||||
}: SourceControlPanelIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
@@ -18,15 +20,15 @@ export function SourceControlPanelIcon({
|
||||
height={18}
|
||||
rx={2}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Plus */}
|
||||
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
{/* Minus */}
|
||||
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -944,9 +944,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]}
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<Paperclip size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Attach images</Text>
|
||||
@@ -972,18 +976,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
? 'Stop dictation'
|
||||
: 'Start dictation'
|
||||
}
|
||||
style={[
|
||||
style={({ hovered }) => [
|
||||
styles.voiceButton,
|
||||
hovered && !isDictating && styles.iconButtonHovered,
|
||||
(!isDictationStartEnabled) && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
]}
|
||||
>
|
||||
{isDictating ? (
|
||||
<Square size={theme.iconSize.lg} color="white" fill="white" />
|
||||
<Square size={theme.iconSize.md} color="white" fill="white" />
|
||||
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
||||
<MicOff size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<MicOff size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -1010,9 +1015,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Queue message"
|
||||
accessibilityRole="button"
|
||||
style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]}
|
||||
style={({ hovered }) => [
|
||||
styles.queueButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Plus size={theme.iconSize.lg} color="white" />
|
||||
<Plus size={theme.iconSize.md} color="white" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
@@ -1034,7 +1043,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
{isSubmitLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<ArrowUp size={theme.iconSize.lg} color="white" />
|
||||
<ArrowUp size={theme.iconSize.md} color="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -1156,9 +1165,9 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
textInput: {
|
||||
width: '100%',
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: theme.fontSize.lg * 1.4,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
...(IS_WEB
|
||||
? {
|
||||
outlineStyle: 'none' as const,
|
||||
@@ -1174,7 +1183,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
},
|
||||
leftButtonGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-end',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
@@ -1183,15 +1192,15 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
attachButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
voiceButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -1200,21 +1209,24 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
backgroundColor: theme.colors.destructive,
|
||||
},
|
||||
queueButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -72,7 +72,11 @@ import {
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import {
|
||||
parseFileProtocolUrl,
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
type InlinePathTarget,
|
||||
} from "@/utils/inline-path";
|
||||
@@ -429,6 +433,7 @@ interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||
workspaceRoot?: string;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
@@ -536,6 +541,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||
alignSelf: "flex-start",
|
||||
padding: theme.spacing[2],
|
||||
paddingTop: 0,
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -619,7 +625,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
|
||||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginHorizontal: -6,
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
@@ -630,8 +636,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
pressable: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: "transparent",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
overflow: "hidden",
|
||||
@@ -655,16 +660,18 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderRadius: 11,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: theme.spacing[2],
|
||||
marginRight: theme.spacing[1],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.88,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 0,
|
||||
},
|
||||
labelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
labelLoading: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.72,
|
||||
@@ -676,6 +683,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
secondaryLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
shimmerText: {
|
||||
color: "transparent",
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -705,6 +715,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
pressableExpanded: {
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
@@ -744,13 +756,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp,
|
||||
onInlinePathPress,
|
||||
workspaceRoot,
|
||||
disableOuterSpacing,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { theme, rt } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||
|
||||
const markdownParser = useMemo(
|
||||
() => {
|
||||
@@ -770,7 +783,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
const handleLinkPress = useCallback((url: string) => {
|
||||
const fileTarget = onInlinePathPress
|
||||
? parseFileProtocolUrl(url)
|
||||
? parseAssistantFileLink(url, { workspaceRoot })
|
||||
: null;
|
||||
if (fileTarget) {
|
||||
onInlinePathPress?.(fileTarget);
|
||||
@@ -781,7 +794,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
}, [onInlinePathPress]);
|
||||
}, [onInlinePathPress, workspaceRoot]);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
return {
|
||||
@@ -935,6 +948,22 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
parent: any,
|
||||
styles: any,
|
||||
) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View
|
||||
key={node.key}
|
||||
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
link: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
@@ -1588,12 +1617,23 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
[isExpanded, isInteractive]
|
||||
);
|
||||
|
||||
const isActive = isHovered || isExpanded;
|
||||
|
||||
const labelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.label,
|
||||
isActive && expandableBadgeStylesheet.labelActive,
|
||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||
],
|
||||
[isLoading]
|
||||
[isActive, isLoading]
|
||||
);
|
||||
|
||||
const secondaryLabelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.secondaryLabel,
|
||||
isActive && expandableBadgeStylesheet.secondaryLabelActive,
|
||||
],
|
||||
[isActive]
|
||||
);
|
||||
|
||||
const shimmerLabelTextStyle = useMemo(
|
||||
@@ -1664,7 +1704,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const IconComponent = icon;
|
||||
const iconColor = isError
|
||||
? theme.colors.destructive
|
||||
: theme.colors.mutedForeground;
|
||||
: isActive
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground;
|
||||
|
||||
let iconNode: ReactNode = null;
|
||||
if (isError) {
|
||||
@@ -1711,7 +1753,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={expandableBadgeStylesheet.secondaryLabel}
|
||||
style={secondaryLabelStyle}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
|
||||
>
|
||||
@@ -1806,7 +1848,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
{isInteractive && isHovered ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
color={theme.colors.foreground}
|
||||
style={chevronStyle}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1921,31 +1963,42 @@ export const ToolCall = memo(function ToolCall({
|
||||
const summary = displayModel.summary;
|
||||
const errorText = displayModel.errorText;
|
||||
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: effectiveDetail,
|
||||
status,
|
||||
error,
|
||||
});
|
||||
const secondaryLabel = summary;
|
||||
|
||||
// Check if there's any content to display
|
||||
const hasDetails =
|
||||
Boolean(error) ||
|
||||
(effectiveDetail
|
||||
? effectiveDetail.type === "plain_text"
|
||||
? Boolean(effectiveDetail.text)
|
||||
: effectiveDetail.type !== "unknown" ||
|
||||
effectiveDetail.input !== null ||
|
||||
effectiveDetail.output !== null
|
||||
: false);
|
||||
hasMeaningfulToolCallDetail(effectiveDetail);
|
||||
const canOpenDetails = hasDetails || isLoadingDetails;
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
displayName,
|
||||
summary,
|
||||
summary: secondaryLabel,
|
||||
detail: effectiveDetail,
|
||||
errorText,
|
||||
showLoadingSkeleton: isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isMobile, openToolCall, toolName, displayName, summary, effectiveDetail, errorText]);
|
||||
}, [
|
||||
isMobile,
|
||||
openToolCall,
|
||||
toolName,
|
||||
displayName,
|
||||
secondaryLabel,
|
||||
effectiveDetail,
|
||||
errorText,
|
||||
isLoadingDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||
@@ -1982,19 +2035,20 @@ export const ToolCall = memo(function ToolCall({
|
||||
detail={effectiveDetail}
|
||||
errorText={errorText}
|
||||
maxHeight={400}
|
||||
showLoadingSkeleton={isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [isMobile, effectiveDetail, errorText]);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={displayName}
|
||||
secondaryLabel={summary}
|
||||
secondaryLabel={secondaryLabel}
|
||||
icon={IconComponent}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
onToggle={hasDetails ? handleToggle : undefined}
|
||||
renderDetails={hasDetails && !isMobile ? renderDetails : undefined}
|
||||
onToggle={canOpenDetails ? handleToggle : undefined}
|
||||
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
isLoading={status === "running" || status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
@@ -294,7 +295,7 @@ export function ProjectPickerModal() {
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{path}
|
||||
{shortenPath(path)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
12
packages/app/src/components/provider-icons.ts
Normal file
12
packages/app/src/components/provider-icons.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Bot } from 'lucide-react-native'
|
||||
import { ClaudeIcon } from '@/components/icons/claude-icon'
|
||||
import { CodexIcon } from '@/components/icons/codex-icon'
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
return PROVIDER_ICONS[provider] ?? Bot
|
||||
}
|
||||
@@ -22,14 +22,13 @@ export function RealtimeVoiceOverlay({
|
||||
onStop,
|
||||
}: RealtimeVoiceOverlayProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.meterContainer}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
190
packages/app/src/components/resize-handle.tsx
Normal file
190
packages/app/src/components/resize-handle.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
direction: "horizontal" | "vertical";
|
||||
groupId: string;
|
||||
index: number;
|
||||
sizes: number[];
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
}
|
||||
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
leftSize: number;
|
||||
rightSize: number;
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
direction,
|
||||
groupId,
|
||||
index,
|
||||
sizes,
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pointerStateRef = useRef<PointerState | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const highlighted = active || dragging;
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: any) => {
|
||||
const hitAreaElement = event.currentTarget as HTMLElement | null;
|
||||
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerElement.getBoundingClientRect();
|
||||
const containerSize = direction === "horizontal" ? rect.width : rect.height;
|
||||
if (containerSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging(true);
|
||||
|
||||
pointerStateRef.current = {
|
||||
containerSize,
|
||||
pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
|
||||
leftSize: sizes[index] ?? 0,
|
||||
rightSize: sizes[index + 1] ?? 0,
|
||||
};
|
||||
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
|
||||
document.body.style.cursor = nextCursor;
|
||||
event.preventDefault();
|
||||
|
||||
function cleanup() {
|
||||
pointerStateRef.current = null;
|
||||
setDragging(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
const pointerState = pointerStateRef.current;
|
||||
if (!pointerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerCurrent =
|
||||
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
const nextSizes = sizes.slice();
|
||||
nextSizes[index] = pointerState.leftSize + deltaRatio;
|
||||
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
|
||||
onResizeSplit(groupId, nextSizes);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.handle,
|
||||
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
|
||||
{ backgroundColor: theme.colors.border },
|
||||
]}
|
||||
>
|
||||
{highlighted && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.highlight,
|
||||
direction === "horizontal"
|
||||
? styles.highlightHorizontal
|
||||
: styles.highlightVertical,
|
||||
{ backgroundColor: theme.colors.accent },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
role="separator"
|
||||
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
||||
style={[
|
||||
styles.hitArea,
|
||||
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
||||
{
|
||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||
} as any,
|
||||
]}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={() => {
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setActive(true);
|
||||
}, 150);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
setActive(false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((_theme) => ({
|
||||
handle: {
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
},
|
||||
handleHorizontal: {
|
||||
width: 1,
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
handleVertical: {
|
||||
height: 1,
|
||||
width: "100%",
|
||||
},
|
||||
highlight: {
|
||||
position: "absolute",
|
||||
zIndex: 5,
|
||||
},
|
||||
highlightHorizontal: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
left: -1,
|
||||
},
|
||||
highlightVertical: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
top: -1,
|
||||
},
|
||||
hitArea: {
|
||||
position: "absolute",
|
||||
zIndex: 10,
|
||||
},
|
||||
hitAreaHorizontal: {
|
||||
left: -5,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
},
|
||||
hitAreaVertical: {
|
||||
top: -5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 10,
|
||||
},
|
||||
}));
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type MutableRefObject,
|
||||
} from 'react'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { navigateToWorkspace } from '@/hooks/use-workspace-navigation'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
|
||||
import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store'
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -1093,7 +1095,7 @@ function ProjectBlock({
|
||||
return
|
||||
}
|
||||
onWorkspacePress?.()
|
||||
router.replace(workspaceRoute as any)
|
||||
navigateToWorkspace(serverId, item.workspaceId)
|
||||
}}
|
||||
drag={input?.drag ?? (() => {})}
|
||||
isDragging={input?.isDragging ?? false}
|
||||
@@ -1147,7 +1149,7 @@ function ProjectBlock({
|
||||
return
|
||||
}
|
||||
onWorkspacePress?.()
|
||||
router.replace(buildHostWorkspaceRoute(serverId, flattenedWorkspace.workspaceId) as any)
|
||||
navigateToWorkspace(serverId, flattenedWorkspace.workspaceId)
|
||||
}}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
@@ -1209,6 +1211,7 @@ export function SidebarWorkspaceList({
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const isNative = Platform.OS !== 'web'
|
||||
const pathname = usePathname()
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
|
||||
const isTauri = getIsTauri()
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||
@@ -1219,19 +1222,8 @@ export function SidebarWorkspaceList({
|
||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder)
|
||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder)
|
||||
|
||||
const activeWorkspaceSelection = useMemo(() => {
|
||||
if (!pathname) {
|
||||
return null
|
||||
}
|
||||
const parsed = parseHostWorkspaceRouteFromPathname(pathname)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
serverId: parsed.serverId,
|
||||
workspaceId: parsed.workspaceId,
|
||||
}
|
||||
}, [pathname])
|
||||
const isWorkspaceRoute = useMemo(() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), [pathname])
|
||||
const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null
|
||||
|
||||
const projectIconRequests = useMemo(() => {
|
||||
if (!serverId) {
|
||||
@@ -1361,6 +1353,7 @@ export function SidebarWorkspaceList({
|
||||
if (!serverId || project.projectKind !== 'git') {
|
||||
return
|
||||
}
|
||||
onWorkspacePress?.()
|
||||
router.push(
|
||||
buildHostNewAgentRoute(serverId, {
|
||||
workingDir: project.iconWorkingDir,
|
||||
@@ -1368,7 +1361,7 @@ export function SidebarWorkspaceList({
|
||||
}) as any
|
||||
)
|
||||
},
|
||||
[serverId]
|
||||
[serverId, onWorkspacePress]
|
||||
)
|
||||
|
||||
const renderProject = useCallback(
|
||||
@@ -1380,7 +1373,7 @@ export function SidebarWorkspaceList({
|
||||
displayName={item.projectName}
|
||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||
serverId={serverId}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
activeWorkspaceSelection={effectiveActiveWorkspaceSelection}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
parentGestureRef={parentGestureRef}
|
||||
@@ -1396,8 +1389,8 @@ export function SidebarWorkspaceList({
|
||||
)
|
||||
},
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
collapsedProjectKeys,
|
||||
effectiveActiveWorkspaceSelection,
|
||||
handleCreateWorktree,
|
||||
handleWorkspaceReorder,
|
||||
onWorkspacePress,
|
||||
@@ -1631,7 +1624,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
workspaceRowContainer: {
|
||||
position: 'relative',
|
||||
|
||||
@@ -12,6 +12,9 @@ export function SortableInlineList<T>({
|
||||
onDragEnd?: (data: T[]) => void;
|
||||
useDragHandle?: boolean;
|
||||
disabled?: boolean;
|
||||
externalDndContext?: boolean;
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -33,6 +33,8 @@ function SortableItem<T>({
|
||||
activeId,
|
||||
useDragHandle,
|
||||
disabled,
|
||||
itemData,
|
||||
externalDndContext,
|
||||
}: {
|
||||
id: string;
|
||||
item: T;
|
||||
@@ -41,6 +43,8 @@ function SortableItem<T>({
|
||||
activeId: string | null;
|
||||
useDragHandle: boolean;
|
||||
disabled: boolean;
|
||||
itemData?: Record<string, unknown>;
|
||||
externalDndContext: boolean;
|
||||
}): ReactElement {
|
||||
const {
|
||||
attributes,
|
||||
@@ -50,24 +54,27 @@ function SortableItem<T>({
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled });
|
||||
} = useSortable({ id, disabled, data: itemData });
|
||||
|
||||
const drag = useCallback(() => {
|
||||
// dnd-kit handles drag initiation via listeners
|
||||
// This is a no-op but matches the mobile API
|
||||
}, []);
|
||||
|
||||
// See `draggable-list.web.tsx` for details on why we zero out dnd-kit scale.
|
||||
const baseTransform = CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
);
|
||||
const scaleTransform = isDragging ? "scale(1.01)" : "";
|
||||
// External DnD contexts render their own insertion affordance, so keep the
|
||||
// tab row static and let the DragOverlay carry the moving chip.
|
||||
const baseTransform = externalDndContext
|
||||
? undefined
|
||||
: CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
);
|
||||
const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
|
||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||
|
||||
const style = {
|
||||
transform: combinedTransform || undefined,
|
||||
transition,
|
||||
opacity: isDragging ? 0.9 : 1,
|
||||
opacity: externalDndContext && isDragging ? 0.3 : isDragging ? 0.9 : 1,
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
};
|
||||
|
||||
@@ -107,6 +114,9 @@ export function SortableInlineList<T>({
|
||||
disabled = false,
|
||||
activationDistance = 8,
|
||||
onDragBegin,
|
||||
externalDndContext = false,
|
||||
activeId: externalActiveId = null,
|
||||
getItemData,
|
||||
}: {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
@@ -116,10 +126,13 @@ export function SortableInlineList<T>({
|
||||
disabled?: boolean;
|
||||
activationDistance?: number;
|
||||
onDragBegin?: () => void;
|
||||
externalDndContext?: boolean;
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||
const items = dragItems ?? data;
|
||||
const items = externalDndContext ? data : dragItems ?? data;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -175,6 +188,32 @@ export function SortableInlineList<T>({
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
|
||||
const renderedItems = (
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={externalDndContext ? externalActiveId : activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
itemData={getItemData?.(item, index)}
|
||||
externalDndContext={externalDndContext}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
);
|
||||
|
||||
if (externalDndContext) {
|
||||
return renderedItems;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -183,23 +222,7 @@ export function SortableInlineList<T>({
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
{renderedItems}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
|
||||
describe("shouldFocusPaneFromEventTarget", () => {
|
||||
it("returns false for links and buttons", () => {
|
||||
expect(
|
||||
shouldFocusPaneFromEventTarget({
|
||||
closest: () => ({ tagName: "A" } as Element),
|
||||
} as unknown as EventTarget)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldFocusPaneFromEventTarget({
|
||||
closest: () => ({ tagName: "BUTTON" } as Element),
|
||||
} as unknown as EventTarget)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for non-interactive pane content", () => {
|
||||
expect(
|
||||
shouldFocusPaneFromEventTarget({
|
||||
closest: () => null,
|
||||
} as unknown as EventTarget)
|
||||
).toBe(true);
|
||||
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
20
packages/app/src/components/split-container-pane-focus.ts
Normal file
20
packages/app/src/components/split-container-pane-focus.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
const INTERACTIVE_TARGET_SELECTOR = [
|
||||
"a",
|
||||
"button",
|
||||
"input",
|
||||
"select",
|
||||
"textarea",
|
||||
"[role='button']",
|
||||
"[role='link']",
|
||||
"[contenteditable='true']",
|
||||
"[data-paseo-pane-focus-exempt='true']",
|
||||
].join(", ");
|
||||
|
||||
export function shouldFocusPaneFromEventTarget(target: EventTarget | null): boolean {
|
||||
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
|
||||
if (!candidate || typeof candidate.closest !== "function") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !candidate.closest(INTERACTIVE_TARGET_SELECTOR);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeTabDropPreview } from "@/components/split-container-tab-drop-preview";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
function tab(tabId: string): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: tabId,
|
||||
tabId,
|
||||
kind: "draft",
|
||||
target: {
|
||||
kind: "draft",
|
||||
draftId: tabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeTabDropPreview", () => {
|
||||
const targetTabs = [tab("a"), tab("b"), tab("c"), tab("d")];
|
||||
|
||||
it("returns a before-target insertion index for cross-pane drops on the left half", () => {
|
||||
expect(
|
||||
computeTabDropPreview({
|
||||
activePaneId: "source",
|
||||
activeTabId: "x",
|
||||
overPaneId: "target",
|
||||
overTabId: "c",
|
||||
targetTabs,
|
||||
activeRect: { left: 180, width: 40 },
|
||||
overRect: { left: 200, width: 100 },
|
||||
})
|
||||
).toEqual({
|
||||
paneId: "target",
|
||||
insertionIndex: 2,
|
||||
indicatorIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an after-target insertion index for cross-pane drops on the right half", () => {
|
||||
expect(
|
||||
computeTabDropPreview({
|
||||
activePaneId: "source",
|
||||
activeTabId: "x",
|
||||
overPaneId: "target",
|
||||
overTabId: "c",
|
||||
targetTabs,
|
||||
activeRect: { left: 280, width: 40 },
|
||||
overRect: { left: 200, width: 100 },
|
||||
})
|
||||
).toEqual({
|
||||
paneId: "target",
|
||||
insertionIndex: 3,
|
||||
indicatorIndex: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("adjusts same-pane drops so insertion indexes match arrayMove semantics", () => {
|
||||
expect(
|
||||
computeTabDropPreview({
|
||||
activePaneId: "pane",
|
||||
activeTabId: "b",
|
||||
overPaneId: "pane",
|
||||
overTabId: "d",
|
||||
targetTabs,
|
||||
activeRect: { left: 460, width: 40 },
|
||||
overRect: { left: 400, width: 100 },
|
||||
})
|
||||
).toEqual({
|
||||
paneId: "pane",
|
||||
insertionIndex: 3,
|
||||
indicatorIndex: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export interface TabDropPreview {
|
||||
paneId: string;
|
||||
insertionIndex: number;
|
||||
indicatorIndex: number;
|
||||
}
|
||||
|
||||
interface ComputeTabDropPreviewInput {
|
||||
activePaneId: string;
|
||||
activeTabId: string;
|
||||
overPaneId: string;
|
||||
overTabId: string;
|
||||
targetTabs: WorkspaceTabDescriptor[];
|
||||
activeRect: {
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
overRect: {
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function computeTabDropPreview(
|
||||
input: ComputeTabDropPreviewInput
|
||||
): TabDropPreview | null {
|
||||
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
|
||||
if (targetIndex < 0 || input.overRect.width <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeCenterX = input.activeRect.left + input.activeRect.width / 2;
|
||||
const overCenterX = input.overRect.left + input.overRect.width / 2;
|
||||
const insertAfterTarget = activeCenterX >= overCenterX;
|
||||
|
||||
const indicatorIndex = targetIndex + (insertAfterTarget ? 1 : 0);
|
||||
let insertionIndex = indicatorIndex;
|
||||
if (input.activePaneId === input.overPaneId) {
|
||||
const sourceIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.activeTabId);
|
||||
if (sourceIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
if (sourceIndex < insertionIndex) {
|
||||
insertionIndex -= 1;
|
||||
}
|
||||
insertionIndex = Math.max(0, Math.min(input.targetTabs.length - 1, insertionIndex));
|
||||
}
|
||||
|
||||
return {
|
||||
paneId: input.overPaneId,
|
||||
insertionIndex,
|
||||
indicatorIndex,
|
||||
};
|
||||
}
|
||||
913
packages/app/src/components/split-container.tsx
Normal file
913
packages/app/src/components/split-container.tsx
Normal file
@@ -0,0 +1,913 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { Platform, View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import {
|
||||
computeTabDropPreview,
|
||||
type TabDropPreview,
|
||||
} from "@/components/split-container-tab-drop-preview";
|
||||
import {
|
||||
SplitDropZone,
|
||||
resolveSplitDropPosition,
|
||||
type SplitDropZoneHover,
|
||||
} from "@/components/split-drop-zone";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
getWorkspacePaneDescriptors,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import {
|
||||
useWorkspaceTabPresentation,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
workspaceKey: string;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
hoveredCloseTabKey: string | null;
|
||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
|
||||
killTerminalPending: boolean;
|
||||
killTerminalId: string | null;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
|
||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||
newTabAgentOptionId?: "__new_tab_agent__";
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
}) => WorkspacePaneContentModel;
|
||||
onFocusPane: (paneId: string) => void;
|
||||
onSplitPane: (input: {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
onSplitPaneEmpty: (input: {
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
renderPaneEmptyState?: () => ReactNode;
|
||||
}
|
||||
|
||||
interface WorkspaceTabDragData {
|
||||
kind: "workspace-tab";
|
||||
paneId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface SplitPaneDropData {
|
||||
kind: "split-pane-drop";
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
interface SplitNodeViewProps
|
||||
extends Omit<SplitContainerProps, "layout"> {
|
||||
node: SplitNode;
|
||||
uiTabs: WorkspaceTab[];
|
||||
focusedPaneId: string;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
}
|
||||
|
||||
interface SplitPaneViewProps
|
||||
extends Omit<
|
||||
SplitNodeViewProps,
|
||||
| "node"
|
||||
| "workspaceKey"
|
||||
| "focusedPaneId"
|
||||
| "activeDragTabId"
|
||||
| "showDropZones"
|
||||
| "dropPreview"
|
||||
| "onMoveTabToPane"
|
||||
| "onResizeSplit"
|
||||
> {
|
||||
pane: SplitPane;
|
||||
uiTabs: WorkspaceTab[];
|
||||
isFocused: boolean;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
}
|
||||
|
||||
const dropCollisionDetection: CollisionDetection = (args) => {
|
||||
const pointerHits = pointerWithin(args);
|
||||
const tabHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab"
|
||||
);
|
||||
if (tabHits.length > 0) {
|
||||
return tabHits;
|
||||
}
|
||||
|
||||
const paneHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop"
|
||||
);
|
||||
if (paneHits.length > 0) {
|
||||
return paneHits;
|
||||
}
|
||||
|
||||
return closestCenter(args);
|
||||
};
|
||||
|
||||
export function SplitContainer({
|
||||
layout,
|
||||
workspaceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
uiTabs,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalPending,
|
||||
killTerminalId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId = "__new_tab_agent__",
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState = () => null,
|
||||
}: SplitContainerProps) {
|
||||
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
if (data?.kind !== "workspace-tab") {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
setActiveDragTabId(data.tabId);
|
||||
}, []);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
}, []);
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(
|
||||
event:
|
||||
| Pick<DragMoveEvent, "active" | "over">
|
||||
| Pick<DragOverEvent, "active" | "over">
|
||||
) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
|
||||
if (activeData?.kind !== "workspace-tab") {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const translatedRect = event.active.rect.current.translated;
|
||||
const overRect = event.over?.rect;
|
||||
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!targetPane) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTabs = getWorkspacePaneDescriptors({
|
||||
pane: targetPane,
|
||||
tabs: uiTabs,
|
||||
});
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(
|
||||
computeTabDropPreview({
|
||||
activePaneId: activeData.paneId,
|
||||
activeTabId: activeData.tabId,
|
||||
overPaneId: overData.paneId,
|
||||
overTabId: overData.tabId,
|
||||
targetTabs,
|
||||
activeRect: {
|
||||
left: translatedRect.left,
|
||||
width: translatedRect.width,
|
||||
},
|
||||
overRect: {
|
||||
left: overRect.left,
|
||||
width: overRect.width,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setTabDropPreview(null);
|
||||
if (overData?.kind !== "split-pane-drop") {
|
||||
setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const centerX = translatedRect.left + translatedRect.width / 2;
|
||||
const centerY = translatedRect.top + translatedRect.height / 2;
|
||||
const relativeX = centerX - overRect.left;
|
||||
const relativeY = centerY - overRect.top;
|
||||
if (
|
||||
Number.isNaN(relativeX) ||
|
||||
Number.isNaN(relativeY) ||
|
||||
relativeX < 0 ||
|
||||
relativeX > overRect.width ||
|
||||
relativeY < 0 ||
|
||||
relativeY > overRect.height
|
||||
) {
|
||||
setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDropPreview({
|
||||
paneId: overData.paneId,
|
||||
position: resolveSplitDropPosition({
|
||||
width: overRect.width,
|
||||
height: overRect.height,
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
}),
|
||||
});
|
||||
},
|
||||
[panesById, uiTabs]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
if (activeData?.kind !== "workspace-tab" || !event.over) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
const sourcePane = panesById.get(activeData.paneId) ?? null;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!sourcePane || !targetPane) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||
const resolvedTabDropPreview =
|
||||
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
|
||||
if (sourceIndex < 0 || !resolvedTabDropPreview) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeData.paneId === overData.paneId) {
|
||||
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
||||
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
|
||||
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
|
||||
}
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
|
||||
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) {
|
||||
if (dropPreview.position === "center") {
|
||||
if (activeData.paneId !== overData.paneId) {
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
}
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSplitPane({
|
||||
tabId: activeData.tabId,
|
||||
targetPaneId: overData.paneId,
|
||||
position: dropPreview.position,
|
||||
});
|
||||
}
|
||||
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
},
|
||||
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={dropCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={updateDropPreview}
|
||||
onDragOver={updateDropPreview}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SplitNodeView
|
||||
node={layout.root}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragTabId ? (
|
||||
<DragOverlayTabChip
|
||||
tabId={activeDragTabId}
|
||||
uiTabs={uiTabs}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChip({
|
||||
tabId,
|
||||
uiTabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tabId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const tab = uiTabs.find((t) => t.tabId === tabId);
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
const descriptor: WorkspaceTabDescriptor = {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
};
|
||||
return (
|
||||
<DragOverlayTabChipInner
|
||||
tab={descriptor}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChipInner({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const presentation = useWorkspaceTabPresentation({
|
||||
tab,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const label =
|
||||
presentation.titleState === "loading" ? "Loading..." : presentation.label;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.dragOverlayChip,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.dragOverlayLabel,
|
||||
{ color: theme.colors.foreground },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitNodeView({
|
||||
node,
|
||||
workspaceKey,
|
||||
uiTabs,
|
||||
focusedPaneId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalPending,
|
||||
killTerminalId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
activeDragTabId,
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
}: SplitNodeViewProps) {
|
||||
if (node.kind === "pane") {
|
||||
return (
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const groupSizes =
|
||||
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ??
|
||||
node.group.sizes;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.group,
|
||||
node.group.direction === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
|
||||
]}
|
||||
>
|
||||
{node.group.children.map((child, index) => (
|
||||
<Fragment key={getNodeKey(child)}>
|
||||
<View style={[styles.groupChild, { flex: groupSizes[index] ?? 1 }]}>
|
||||
<SplitNodeView
|
||||
node={child}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
</View>
|
||||
{index < node.group.children.length - 1 ? (
|
||||
<ResizeHandle
|
||||
direction={node.group.direction}
|
||||
groupId={node.group.id}
|
||||
index={index}
|
||||
sizes={groupSizes}
|
||||
onResizeSplit={onResizeSplit}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitPaneView({
|
||||
pane,
|
||||
uiTabs,
|
||||
isFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalPending,
|
||||
killTerminalId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
activeDragTabId,
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
}: SplitPaneViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const paneRef = useRef<View | null>(null);
|
||||
const paneState = useMemo(
|
||||
() =>
|
||||
deriveWorkspacePaneState({
|
||||
pane,
|
||||
tabs: uiTabs,
|
||||
}),
|
||||
[pane, uiTabs]
|
||||
);
|
||||
const paneTabs = useMemo(
|
||||
() => paneState.tabs.map((tab) => tab.descriptor),
|
||||
[paneState.tabs]
|
||||
);
|
||||
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
|
||||
() =>
|
||||
paneTabs.map((tab) => {
|
||||
const isClosingAgent =
|
||||
tab.target.kind === "agent" &&
|
||||
isArchivingAgent({
|
||||
serverId: normalizedServerId,
|
||||
agentId: tab.target.agentId,
|
||||
});
|
||||
const isClosingTerminal =
|
||||
tab.target.kind === "terminal" &&
|
||||
killTerminalPending &&
|
||||
killTerminalId === tab.target.terminalId;
|
||||
|
||||
return {
|
||||
tab,
|
||||
isActive: tab.key === activeTabDescriptor?.key,
|
||||
isCloseHovered: hoveredCloseTabKey === tab.key,
|
||||
isClosingTab: isClosingAgent || isClosingTerminal,
|
||||
};
|
||||
}),
|
||||
[
|
||||
activeTabDescriptor?.key,
|
||||
hoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalId,
|
||||
killTerminalPending,
|
||||
normalizedServerId,
|
||||
paneTabs,
|
||||
]
|
||||
);
|
||||
const paneContent = useMemo(
|
||||
() =>
|
||||
activeTabDescriptor
|
||||
? buildPaneContentModel({
|
||||
paneId: pane.id,
|
||||
tab: activeTabDescriptor,
|
||||
})
|
||||
: null,
|
||||
[
|
||||
activeTabDescriptor,
|
||||
buildPaneContentModel,
|
||||
pane.id,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
|
||||
const paneElement = paneRef.current as unknown as HTMLElement | null;
|
||||
if (
|
||||
!paneElement ||
|
||||
typeof paneElement.addEventListener !== "function" ||
|
||||
typeof paneElement.removeEventListener !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePanePointerDown = (event: PointerEvent) => {
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
onFocusPane(pane.id);
|
||||
};
|
||||
|
||||
const handlePaneFocusIn = (event: FocusEvent) => {
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
onFocusPane(pane.id);
|
||||
};
|
||||
|
||||
paneElement.addEventListener("pointerdown", handlePanePointerDown, true);
|
||||
paneElement.addEventListener("focusin", handlePaneFocusIn, true);
|
||||
|
||||
return () => {
|
||||
paneElement.removeEventListener("pointerdown", handlePanePointerDown, true);
|
||||
paneElement.removeEventListener("focusin", handlePaneFocusIn, true);
|
||||
};
|
||||
}, [onFocusPane, pane.id]);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={paneRef}
|
||||
collapsable={false}
|
||||
style={styles.pane}
|
||||
>
|
||||
<View style={styles.paneTabs}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
||||
onReorderTabs={(nextTabs) => {
|
||||
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
|
||||
}}
|
||||
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
|
||||
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
|
||||
externalDndContext
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{paneContent ? (
|
||||
<WorkspacePaneContent content={paneContent} />
|
||||
) : (
|
||||
renderPaneEmptyState?.() ?? null
|
||||
)}
|
||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
|
||||
const next = new Map<string, SplitPane>();
|
||||
function visit(current: SplitNode) {
|
||||
if (current.kind === "pane") {
|
||||
next.set(current.pane.id, current.pane);
|
||||
return;
|
||||
}
|
||||
for (const child of current.group.children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
visit(node);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getNodeKey(node: SplitNode): string {
|
||||
if (node.kind === "pane") {
|
||||
return node.pane.id;
|
||||
}
|
||||
return node.group.id;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
group: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
groupHorizontal: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
groupVertical: {
|
||||
flexDirection: "column",
|
||||
},
|
||||
groupChild: {
|
||||
flexBasis: 0,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
pane: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
paneTabs: {
|
||||
minWidth: 0,
|
||||
},
|
||||
paneContent: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
dragOverlayChip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
maxWidth: 200,
|
||||
},
|
||||
dragOverlayLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
208
packages/app/src/components/split-drop-zone.tsx
Normal file
208
packages/app/src/components/split-drop-zone.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useMemo } from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom";
|
||||
|
||||
export interface SplitDropZoneHover {
|
||||
paneId: string;
|
||||
position: SplitDropZonePosition;
|
||||
}
|
||||
|
||||
export interface SplitDropZoneProps {
|
||||
paneId: string;
|
||||
active: boolean;
|
||||
preview: SplitDropZoneHover | null;
|
||||
}
|
||||
|
||||
const EDGE_RATIO = 0.15;
|
||||
const CENTER_RATIO = 0.4;
|
||||
|
||||
export function buildSplitDropZoneId(paneId: string): string {
|
||||
return `split-pane-drop:${paneId}`;
|
||||
}
|
||||
|
||||
export function SplitDropZone({
|
||||
paneId,
|
||||
active,
|
||||
preview,
|
||||
}: SplitDropZoneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: buildSplitDropZoneId(paneId),
|
||||
disabled: !active,
|
||||
data: {
|
||||
kind: "split-pane-drop",
|
||||
paneId,
|
||||
},
|
||||
});
|
||||
|
||||
const previewStyles = useMemo(() => {
|
||||
if (!preview || preview.paneId !== paneId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
overlay: [
|
||||
styles.previewOverlay,
|
||||
getPreviewOverlayStyle(preview.position),
|
||||
{
|
||||
backgroundColor: theme.colors.accent,
|
||||
opacity: 0.6,
|
||||
},
|
||||
],
|
||||
frame: [
|
||||
styles.previewFrame,
|
||||
getPreviewFrameStyle(preview.position),
|
||||
{
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [paneId, preview, theme.colors.accent]);
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={setNodeRef as any}
|
||||
style={styles.overlay}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{previewStyles ? (
|
||||
<>
|
||||
<View pointerEvents="none" style={previewStyles.overlay} />
|
||||
<View pointerEvents="none" style={previewStyles.frame} />
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSplitDropPosition(input: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}): SplitDropZonePosition {
|
||||
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
|
||||
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
|
||||
const insideCenterX =
|
||||
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
|
||||
const insideCenterY =
|
||||
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
|
||||
|
||||
if (insideCenterX && insideCenterY) {
|
||||
return "center";
|
||||
}
|
||||
|
||||
const edgeThresholdX = input.width * EDGE_RATIO;
|
||||
const edgeThresholdY = input.height * EDGE_RATIO;
|
||||
if (input.x <= edgeThresholdX) {
|
||||
return "left";
|
||||
}
|
||||
if (input.x >= input.width - edgeThresholdX) {
|
||||
return "right";
|
||||
}
|
||||
if (input.y <= edgeThresholdY) {
|
||||
return "top";
|
||||
}
|
||||
if (input.y >= input.height - edgeThresholdY) {
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
const distances = [
|
||||
{ position: "left", distance: input.x },
|
||||
{ position: "right", distance: input.width - input.x },
|
||||
{ position: "top", distance: input.y },
|
||||
{ position: "bottom", distance: input.height - input.y },
|
||||
] satisfies Array<{ position: Exclude<SplitDropZonePosition, "center">; distance: number }>;
|
||||
distances.sort((left, right) => left.distance - right.distance);
|
||||
return distances[0]?.position ?? "center";
|
||||
}
|
||||
|
||||
function getPreviewOverlayStyle(position: SplitDropZonePosition) {
|
||||
if (position === "left") {
|
||||
return styles.previewLeft;
|
||||
}
|
||||
if (position === "right") {
|
||||
return styles.previewRight;
|
||||
}
|
||||
if (position === "top") {
|
||||
return styles.previewTop;
|
||||
}
|
||||
if (position === "bottom") {
|
||||
return styles.previewBottom;
|
||||
}
|
||||
return styles.previewCenterOverlay;
|
||||
}
|
||||
|
||||
function getPreviewFrameStyle(position: SplitDropZonePosition) {
|
||||
if (position === "left") {
|
||||
return styles.previewLeft;
|
||||
}
|
||||
if (position === "right") {
|
||||
return styles.previewRight;
|
||||
}
|
||||
if (position === "top") {
|
||||
return styles.previewTop;
|
||||
}
|
||||
if (position === "bottom") {
|
||||
return styles.previewBottom;
|
||||
}
|
||||
return styles.previewCenterFrame;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 40,
|
||||
},
|
||||
previewOverlay: {
|
||||
position: "absolute",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
previewFrame: {
|
||||
position: "absolute",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 2,
|
||||
},
|
||||
previewLeft: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "50%",
|
||||
},
|
||||
previewRight: {
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "50%",
|
||||
},
|
||||
previewTop: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
},
|
||||
previewBottom: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: "50%",
|
||||
},
|
||||
previewCenterOverlay: {
|
||||
left: theme.spacing[2],
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
bottom: theme.spacing[2],
|
||||
},
|
||||
previewCenterFrame: {
|
||||
left: theme.spacing[2],
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
bottom: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
"use dom";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DOMProps } from "expo/dom";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
@@ -8,9 +8,29 @@ import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
summarizeTerminalText,
|
||||
terminalDebugLog,
|
||||
} from "../terminal/runtime/terminal-debug";
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
|
||||
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
|
||||
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
|
||||
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
|
||||
const SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING = 8;
|
||||
const SCROLLBAR_HANDLE_OPACITY_VISIBLE = 0.62;
|
||||
const SCROLLBAR_HANDLE_OPACITY_HOVERED = 0.78;
|
||||
const SCROLLBAR_HANDLE_OPACITY_DRAGGING = 0.9;
|
||||
const SCROLLBAR_HANDLE_FADE_DURATION_MS = 220;
|
||||
const SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
|
||||
const SCROLLBAR_HANDLE_TRAVEL_DURATION_MS = 90;
|
||||
const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200;
|
||||
const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110;
|
||||
const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style";
|
||||
|
||||
type ViewportMetrics = {
|
||||
offset: number;
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
};
|
||||
|
||||
function buildXtermThemeKey(theme: ITheme): string {
|
||||
const values: Array<string> = [
|
||||
@@ -74,6 +94,34 @@ declare global {
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function ensureTerminalScrollbarStyle(): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (document.getElementById(WEBKIT_SCROLLBAR_STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = WEBKIT_SCROLLBAR_STYLE_ID;
|
||||
styleElement.textContent = `
|
||||
[data-terminal-scrollbar-root="true"] .xterm-viewport {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
export default function TerminalEmulator({
|
||||
streamKey,
|
||||
initialOutputText,
|
||||
@@ -104,13 +152,32 @@ export default function TerminalEmulator({
|
||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||
const appliedChunkSequenceRef = useRef(0);
|
||||
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
||||
const viewportRef = useRef<HTMLElement | null>(null);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastObservedOffsetRef = useRef<number | null>(null);
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
const [viewportMetrics, setViewportMetrics] = useState<ViewportMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
const [isHandleHovered, setIsHandleHovered] = useState(false);
|
||||
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
|
||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
mountedThemeRef.current = xtermTheme;
|
||||
runtimeRef.current?.setTheme({ theme: xtermTheme });
|
||||
}, [themeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureTerminalScrollbarStyle();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
@@ -287,14 +354,6 @@ export default function TerminalEmulator({
|
||||
}
|
||||
|
||||
if (outputChunkSequence <= appliedChunkSequenceRef.current) {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-component",
|
||||
event: "output:chunk:skip-duplicate",
|
||||
details: {
|
||||
sequence: outputChunkSequence,
|
||||
lastAppliedSequence: appliedChunkSequenceRef.current,
|
||||
},
|
||||
});
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
return;
|
||||
}
|
||||
@@ -307,13 +366,6 @@ export default function TerminalEmulator({
|
||||
appliedChunkSequenceRef.current = outputChunkSequence;
|
||||
|
||||
if (outputChunkText.length === 0) {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-component",
|
||||
event: "output:chunk:clear",
|
||||
details: {
|
||||
sequence: outputChunkSequence,
|
||||
},
|
||||
});
|
||||
runtime.clear({
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
@@ -321,16 +373,6 @@ export default function TerminalEmulator({
|
||||
});
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "emulator-component",
|
||||
event: "output:chunk:write",
|
||||
details: {
|
||||
sequence: outputChunkSequence,
|
||||
replay: outputChunkReplay,
|
||||
length: outputChunkText.length,
|
||||
preview: summarizeTerminalText({ text: outputChunkText, maxChars: 80 }),
|
||||
},
|
||||
});
|
||||
runtime.write({
|
||||
text: outputChunkText,
|
||||
suppressInput: outputChunkReplay,
|
||||
@@ -366,10 +408,206 @@ export default function TerminalEmulator({
|
||||
runtimeRef.current?.resize({ force: true });
|
||||
}, [resizeRequestToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (!viewportElement) {
|
||||
viewportRef.current = null;
|
||||
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
viewportRef.current = viewportElement;
|
||||
|
||||
const updateViewportMetrics = () => {
|
||||
setViewportMetrics({
|
||||
offset: Math.max(0, viewportElement.scrollTop),
|
||||
viewportSize: Math.max(0, viewportElement.clientHeight),
|
||||
contentSize: Math.max(0, viewportElement.scrollHeight),
|
||||
});
|
||||
};
|
||||
|
||||
updateViewportMetrics();
|
||||
|
||||
const handleViewportScroll = () => {
|
||||
updateViewportMetrics();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateViewportMetrics();
|
||||
});
|
||||
resizeObserver.observe(viewportElement);
|
||||
const scrollAreaElement = host.querySelector<HTMLElement>(".xterm-scroll-area");
|
||||
if (scrollAreaElement) {
|
||||
resizeObserver.observe(scrollAreaElement);
|
||||
}
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
updateViewportMetrics();
|
||||
});
|
||||
mutationObserver.observe(host, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
});
|
||||
|
||||
viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
viewportElement.removeEventListener("scroll", handleViewportScroll);
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
if (viewportRef.current === viewportElement) {
|
||||
viewportRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxScrollOffset = Math.max(
|
||||
0,
|
||||
viewportMetrics.contentSize - viewportMetrics.viewportSize
|
||||
);
|
||||
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
|
||||
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
|
||||
setIsScrollVisible(false);
|
||||
setIsScrollActive(false);
|
||||
lastObservedOffsetRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOffset = lastObservedOffsetRef.current;
|
||||
lastObservedOffsetRef.current = normalizedOffset;
|
||||
if (previousOffset === null || Math.abs(previousOffset - normalizedOffset) <= 0.5) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScrollVisible(true);
|
||||
if (scrollVisibilityTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollVisibilityTimeoutRef.current);
|
||||
}
|
||||
scrollVisibilityTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrollVisible(false);
|
||||
scrollVisibilityTimeoutRef.current = null;
|
||||
}, SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS);
|
||||
|
||||
setIsScrollActive(true);
|
||||
if (scrollActiveTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollActiveTimeoutRef.current);
|
||||
}
|
||||
scrollActiveTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrollActive(false);
|
||||
scrollActiveTimeoutRef.current = null;
|
||||
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
|
||||
}, [
|
||||
viewportMetrics.contentSize,
|
||||
viewportMetrics.offset,
|
||||
viewportMetrics.viewportSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollVisibilityTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollVisibilityTimeoutRef.current);
|
||||
}
|
||||
if (scrollActiveTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollActiveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollbarGeometry = useMemo(
|
||||
() =>
|
||||
computeVerticalScrollbarGeometry({
|
||||
viewportSize: viewportMetrics.viewportSize,
|
||||
contentSize: viewportMetrics.contentSize,
|
||||
offset: viewportMetrics.offset,
|
||||
}),
|
||||
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingScrollbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const dragDelta = event.clientY - dragStartClientYRef.current;
|
||||
const nextOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: dragStartOffsetRef.current,
|
||||
dragDelta,
|
||||
maxScrollOffset: scrollbarGeometry.maxScrollOffset,
|
||||
maxHandleOffset: scrollbarGeometry.maxHandleOffset,
|
||||
});
|
||||
const viewportElement = viewportRef.current;
|
||||
if (!viewportElement) {
|
||||
return;
|
||||
}
|
||||
viewportElement.scrollTop = nextOffset;
|
||||
setViewportMetrics({
|
||||
offset: nextOffset,
|
||||
viewportSize: Math.max(0, viewportElement.clientHeight),
|
||||
contentSize: Math.max(0, viewportElement.scrollHeight),
|
||||
});
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
setIsDraggingScrollbar(false);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopDragging);
|
||||
window.addEventListener("pointercancel", stopDragging);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
};
|
||||
}, [
|
||||
isDraggingScrollbar,
|
||||
scrollbarGeometry.maxHandleOffset,
|
||||
scrollbarGeometry.maxScrollOffset,
|
||||
]);
|
||||
|
||||
const handleVisible =
|
||||
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
|
||||
const handleOpacity = isDraggingScrollbar
|
||||
? SCROLLBAR_HANDLE_OPACITY_DRAGGING
|
||||
: isHandleHovered
|
||||
? SCROLLBAR_HANDLE_OPACITY_HOVERED
|
||||
: isScrollVisible
|
||||
? SCROLLBAR_HANDLE_OPACITY_VISIBLE
|
||||
: 0;
|
||||
const handleWidth =
|
||||
isDraggingScrollbar || isHandleHovered
|
||||
? SCROLLBAR_HANDLE_WIDTH_ACTIVE
|
||||
: SCROLLBAR_HANDLE_WIDTH_IDLE;
|
||||
const thumbRegionOffset = Math.max(
|
||||
0,
|
||||
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING
|
||||
);
|
||||
const thumbRegionHeight = Math.min(
|
||||
viewportMetrics.viewportSize - thumbRegionOffset,
|
||||
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2
|
||||
);
|
||||
const handleInsetTop = Math.max(
|
||||
0,
|
||||
(thumbRegionHeight - scrollbarGeometry.handleSize) / 2
|
||||
);
|
||||
const handleTravelDurationMs =
|
||||
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid={testId}
|
||||
data-terminal-scrollbar-root="true"
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
@@ -383,10 +621,6 @@ export default function TerminalEmulator({
|
||||
touchAction: "pan-y",
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-component",
|
||||
event: "surface:pointer-down-focus",
|
||||
});
|
||||
runtimeRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
@@ -400,8 +634,82 @@ export default function TerminalEmulator({
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
/>
|
||||
{scrollbarGeometry.isVisible ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: -3,
|
||||
width: SCROLLBAR_HANDLE_GRAB_WIDTH,
|
||||
height: thumbRegionHeight,
|
||||
transform: `translateY(${thumbRegionOffset}px)`,
|
||||
cursor: isDraggingScrollbar ? "grabbing" : "grab",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
transitionProperty: "transform",
|
||||
transitionDuration: `${handleTravelDurationMs}ms`,
|
||||
transitionTimingFunction: "linear",
|
||||
pointerEvents: handleVisible ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragStartOffsetRef.current = clamp(
|
||||
viewportMetrics.offset,
|
||||
0,
|
||||
scrollbarGeometry.maxScrollOffset
|
||||
);
|
||||
dragStartClientYRef.current = event.clientY;
|
||||
setIsDraggingScrollbar(true);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (!isScrollVisible && !isDraggingScrollbar) {
|
||||
return;
|
||||
}
|
||||
setIsHandleHovered(true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setIsHandleHovered(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginTop: handleInsetTop,
|
||||
height: scrollbarGeometry.handleSize,
|
||||
width: handleWidth,
|
||||
borderRadius: 999,
|
||||
alignSelf: "center",
|
||||
backgroundColor: "rgba(113, 113, 122, 1)",
|
||||
opacity: handleOpacity,
|
||||
transitionProperty: "opacity, width, background-color",
|
||||
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
|
||||
transitionTimingFunction:
|
||||
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,10 +35,6 @@ import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "@/terminal/runtime/terminal-stream-controller";
|
||||
import {
|
||||
summarizeTerminalText,
|
||||
terminalDebugLog,
|
||||
} from "@/terminal/runtime/terminal-debug";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
@@ -366,7 +362,7 @@ export function TerminalPane({
|
||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
if (!client || !isConnected || !isScreenFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -716,17 +712,6 @@ export function TerminalPane({
|
||||
meta: input.meta,
|
||||
},
|
||||
};
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:key:send",
|
||||
details: {
|
||||
key: normalizedKey,
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
alt: input.alt,
|
||||
activeStreamId: getCurrentActiveStreamId(),
|
||||
},
|
||||
});
|
||||
if (!dispatchTerminalInputEntry(pendingEntry)) {
|
||||
enqueuePendingTerminalInput(pendingEntry);
|
||||
}
|
||||
@@ -745,16 +730,6 @@ export function TerminalPane({
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentStreamId = getCurrentActiveStreamId();
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:data:received",
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (hasPendingTerminalModifiers(modifiers)) {
|
||||
const pendingResolution = resolvePendingModifierDataInput({
|
||||
@@ -788,15 +763,6 @@ export function TerminalPane({
|
||||
});
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:data:send",
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
const pendingEntry: PendingTerminalInput = {
|
||||
type: "data",
|
||||
data,
|
||||
@@ -835,15 +801,6 @@ export function TerminalPane({
|
||||
return;
|
||||
}
|
||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "display:resize:send",
|
||||
details: {
|
||||
terminalId: selectedTerminalId,
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
},
|
||||
});
|
||||
client.sendTerminalInput(selectedTerminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
@@ -871,13 +828,6 @@ export function TerminalPane({
|
||||
}, [clearPendingModifiers]);
|
||||
|
||||
const handleOutputChunkConsumed = useCallback((sequence: number) => {
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "output:chunk:consumed",
|
||||
details: {
|
||||
sequence,
|
||||
},
|
||||
});
|
||||
outputSession.consume({ sequence });
|
||||
}, [outputSession]);
|
||||
|
||||
@@ -1040,7 +990,7 @@ export function TerminalPane({
|
||||
) : null}
|
||||
|
||||
<View style={styles.outputContainer}>
|
||||
{selectedTerminal ? (
|
||||
{selectedTerminal && isScreenFocused ? (
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
dom={{
|
||||
@@ -1084,13 +1034,15 @@ export function TerminalPane({
|
||||
resizeRequestToken={resizeRequestToken}
|
||||
/>
|
||||
</View>
|
||||
) : selectedTerminal ? (
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
) : (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.stateText}>No terminal selected</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isAttaching ? (
|
||||
{isAttaching && isScreenFocused ? (
|
||||
<View
|
||||
style={styles.attachOverlay}
|
||||
pointerEvents="none"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildLineDiff,
|
||||
parseUnifiedDiff,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
|
||||
@@ -20,6 +21,7 @@ interface ToolCallDetailsContentProps {
|
||||
errorText?: string;
|
||||
maxHeight?: number;
|
||||
fillAvailableHeight?: boolean;
|
||||
showLoadingSkeleton?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({
|
||||
@@ -27,6 +29,7 @@ export function ToolCallDetailsContent({
|
||||
errorText,
|
||||
maxHeight,
|
||||
fillAvailableHeight = false,
|
||||
showLoadingSkeleton = false,
|
||||
}: ToolCallDetailsContentProps) {
|
||||
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
||||
|
||||
@@ -269,7 +272,11 @@ export function ToolCallDetailsContent({
|
||||
const sectionsFromTopLevel = [
|
||||
{ title: "Input", value: detail.input },
|
||||
{ title: "Output", value: detail.output },
|
||||
].filter((entry) => entry.value !== null && entry.value !== undefined);
|
||||
].filter((entry) => hasMeaningfulToolCallDetail({
|
||||
type: "unknown",
|
||||
input: entry.value ?? null,
|
||||
output: null,
|
||||
}));
|
||||
|
||||
for (const section of sectionsFromTopLevel) {
|
||||
let value = "";
|
||||
@@ -327,6 +334,20 @@ export function ToolCallDetailsContent({
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
if (showLoadingSkeleton) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.loadingContainer,
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
]}
|
||||
>
|
||||
<View style={styles.loadingLineWide} />
|
||||
<View style={styles.loadingLineMedium} />
|
||||
<View style={styles.loadingLineShort} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text style={styles.emptyStateText}>No additional details available</Text>
|
||||
);
|
||||
@@ -471,5 +492,27 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
loadingContainer: {
|
||||
gap: theme.spacing[2],
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
loadingLineWide: {
|
||||
height: 12,
|
||||
width: "100%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
loadingLineMedium: {
|
||||
height: 12,
|
||||
width: "72%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
loadingLineShort: {
|
||||
height: 12,
|
||||
width: "48%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ToolCallSheetData = {
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
errorText?: string;
|
||||
showLoadingSkeleton?: boolean;
|
||||
};
|
||||
|
||||
interface ToolCallSheetContextValue {
|
||||
@@ -109,6 +110,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
stackBehavior="replace"
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
@@ -130,7 +132,7 @@ interface ToolCallSheetContentProps {
|
||||
}
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { toolName, displayName, detail, errorText } = data;
|
||||
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
|
||||
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
|
||||
@@ -158,6 +160,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
detail={detail}
|
||||
errorText={errorText}
|
||||
fillAvailableHeight
|
||||
showLoadingSkeleton={showLoadingSkeleton}
|
||||
/>
|
||||
</BottomSheetScrollView>
|
||||
</View>
|
||||
|
||||
@@ -93,7 +93,7 @@ function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
export interface SearchInputProps {
|
||||
placeholder: string
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
@@ -101,7 +101,7 @@ interface SearchInputProps {
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
export function SearchInput({
|
||||
placeholder,
|
||||
value,
|
||||
onChangeText,
|
||||
@@ -144,6 +144,7 @@ export interface ComboboxItemProps {
|
||||
label: string
|
||||
description?: string
|
||||
kind?: 'directory' | 'file'
|
||||
leadingSlot?: ReactNode
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
@@ -154,12 +155,26 @@ export function ComboboxItem({
|
||||
label,
|
||||
description,
|
||||
kind,
|
||||
leadingSlot,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
const { theme } = useUnistyles()
|
||||
|
||||
const leadingContent = leadingSlot ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>
|
||||
) : kind === 'directory' || kind === 'file' ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
{kind === 'directory' ? (
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
@@ -171,15 +186,7 @@ export function ComboboxItem({
|
||||
active && styles.comboboxItemActive,
|
||||
]}
|
||||
>
|
||||
{kind === 'directory' || kind === 'file' ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
{kind === 'directory' ? (
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{leadingContent}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
{label}
|
||||
|
||||
@@ -23,6 +23,7 @@ type SegmentedControlProps<T extends string> = {
|
||||
value: T;
|
||||
onValueChange: (value: T) => void;
|
||||
size?: SegmentedControlSize;
|
||||
hideLabels?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
};
|
||||
@@ -32,6 +33,7 @@ export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onValueChange,
|
||||
size = "md",
|
||||
hideLabels = false,
|
||||
style,
|
||||
testID,
|
||||
}: SegmentedControlProps<T>) {
|
||||
@@ -72,12 +74,14 @@ export function SegmentedControl<T extends string>({
|
||||
{option.icon({ color: iconColor, size: iconSize })}
|
||||
</View>
|
||||
) : null}
|
||||
<Text
|
||||
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{hideLabels ? null : (
|
||||
<Text
|
||||
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
|
||||
|
||||
export function VoiceCompactIndicator() {
|
||||
const { theme } = useUnistyles();
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||
const {
|
||||
isVoiceMode,
|
||||
isVoiceSwitching,
|
||||
@@ -24,7 +24,6 @@ export function VoiceCompactIndicator() {
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
variant="compact"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useHosts } from "@/runtime/host-runtime";
|
||||
export function VoicePanel() {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
||||
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||
const {
|
||||
isMuted,
|
||||
stopVoice,
|
||||
@@ -27,7 +27,6 @@ export function VoicePanel() {
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
variant="compact"
|
||||
|
||||
@@ -13,7 +13,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
interface VolumeMeterProps {
|
||||
volume: number;
|
||||
isMuted?: boolean;
|
||||
isDetecting?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
variant?: "default" | "compact";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -12,12 +13,23 @@ import { NameHostModal } from "./name-host-modal";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
|
||||
type WelcomeAction = {
|
||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||
label: string;
|
||||
testID: string;
|
||||
primary: boolean;
|
||||
icon: typeof QrCode;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[6],
|
||||
paddingBottom: 0,
|
||||
alignItems: "center",
|
||||
},
|
||||
content: {
|
||||
@@ -26,11 +38,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
logo: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xl,
|
||||
@@ -61,8 +68,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
actionButtonPrimary: {
|
||||
backgroundColor: theme.colors.palette.blue[500],
|
||||
borderColor: theme.colors.palette.blue[500],
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
actionText: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -70,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
actionTextPrimary: {
|
||||
color: theme.colors.palette.white,
|
||||
color: theme.colors.accentForeground,
|
||||
},
|
||||
versionLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -86,6 +93,7 @@ export interface WelcomeScreenProps {
|
||||
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { renameHost } = useHostMutations();
|
||||
const appVersion = resolveAppVersion();
|
||||
@@ -111,51 +119,87 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
[router]
|
||||
);
|
||||
|
||||
const actions: WelcomeAction[] = Platform.OS === "web"
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: "Scan QR code",
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
onPress: () => router.push("/pair-scan?source=onboarding"),
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||
contentContainerStyle={styles.container}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: theme.spacing[6] + insets.bottom },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="welcome-screen"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Image
|
||||
source={require("../../assets/images/icon.png")}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Add a host to start.</Text>
|
||||
<Text style={styles.subtitle}>Connect to your host to start</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.actionButtonPrimary]}
|
||||
onPress={() => setIsDirectOpen(true)}
|
||||
testID="welcome-direct-connection"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.palette.white} />
|
||||
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => setIsPasteLinkOpen(true)}
|
||||
testID="welcome-paste-pairing-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Paste pairing link</Text>
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS !== "web" ? (
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => router.push("/pair-scan?source=onboarding")}
|
||||
testID="welcome-scan-qr"
|
||||
key={action.key}
|
||||
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
|
||||
onPress={action.onPress}
|
||||
testID={action.testID}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Scan QR code</Text>
|
||||
<Icon
|
||||
size={18}
|
||||
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
|
||||
/>
|
||||
<Text style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ export const FOOTER_HEIGHT = 75;
|
||||
// This ensures both headers have the same visual height
|
||||
export const HEADER_INNER_HEIGHT = 48;
|
||||
export const HEADER_INNER_HEIGHT_MOBILE = 56;
|
||||
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
|
||||
export const HEADER_TOP_PADDING_MOBILE = 8;
|
||||
|
||||
// Max width for chat content (stream view, input area, new agent form)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user