mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f2e01131 | ||
|
|
f4f3e4204d | ||
|
|
d7d1e2d169 | ||
|
|
6ab97c579e | ||
|
|
3d4ac57bd0 | ||
|
|
efb3df8233 | ||
|
|
a952112910 | ||
|
|
20fa1a3a3b | ||
|
|
7609ccee4c | ||
|
|
9065dcef54 | ||
|
|
4ab307b10f | ||
|
|
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 |
@@ -428,6 +428,50 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Patch AppImage for Wayland compatibility
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
appimage_dir="$GITHUB_WORKSPACE/packages/desktop/src-tauri/target/release/bundle/appimage"
|
||||
appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
||||
if [ -z "$appimage" ]; then
|
||||
echo "::error::No AppImage found to patch"
|
||||
exit 1
|
||||
fi
|
||||
echo "Patching $appimage for Wayland compatibility..."
|
||||
|
||||
# Extract the AppImage
|
||||
workdir="$(mktemp -d)"
|
||||
cd "$workdir"
|
||||
"$appimage" --appimage-extract
|
||||
|
||||
# Remove GDK_BACKEND=x11 from the GTK plugin hook.
|
||||
# The bundled libgdk-3.so has Wayland support built in; forcing x11
|
||||
# prevents the app from starting on Wayland-only systems.
|
||||
gtk_hook="squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh"
|
||||
if [ -f "$gtk_hook" ]; then
|
||||
sed -i 's/^export GDK_BACKEND=x11/#export GDK_BACKEND=x11/' "$gtk_hook"
|
||||
echo "Patched $gtk_hook — removed GDK_BACKEND=x11"
|
||||
fi
|
||||
|
||||
# Repackage
|
||||
tools_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage \
|
||||
-o "$tools_dir/appimagetool"
|
||||
chmod +x "$tools_dir/appimagetool"
|
||||
ARCH=x86_64 "$tools_dir/appimagetool" squashfs-root "$appimage"
|
||||
|
||||
# Re-sign
|
||||
rm -f "$appimage.sig"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
npx tauri signer sign "$appimage"
|
||||
echo "Repackaged and re-signed $appimage"
|
||||
|
||||
- name: Upload Linux release assets
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+105
-14
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -7186,6 +7186,17 @@
|
||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/cpp": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz",
|
||||
"integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
|
||||
@@ -7197,6 +7208,17 @@
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/go": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
|
||||
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
@@ -7217,6 +7239,17 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/java": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
|
||||
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
@@ -7258,6 +7291,17 @@
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/php": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
|
||||
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
|
||||
@@ -7269,6 +7313,39 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/rust": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz",
|
||||
"integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/xml": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
|
||||
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/yaml": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
|
||||
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -31450,25 +31527,32 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"dependencies": {
|
||||
"@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/expo-two-way-audio": "0.1.28",
|
||||
"@getpaseo/server": "0.1.28",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.30",
|
||||
"@getpaseo/server": "0.1.30",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/go": "^1.0.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/java": "^1.1.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/php": "^1.0.5",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@lezer/rust": "^1.0.2",
|
||||
"@lezer/xml": "^1.0.6",
|
||||
"@lezer/yaml": "^1.0.4",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
@@ -31576,11 +31660,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.28",
|
||||
"@getpaseo/server": "0.1.28",
|
||||
"@getpaseo/relay": "0.1.30",
|
||||
"@getpaseo/server": "0.1.30",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -31617,14 +31701,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -31661,7 +31745,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -31677,20 +31761,27 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.28",
|
||||
"@getpaseo/relay": "0.1.30",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/go": "^1.0.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/java": "^1.1.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/php": "^1.0.5",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@lezer/rust": "^1.0.2",
|
||||
"@lezer/xml": "^1.0.6",
|
||||
"@lezer/yaml": "^1.0.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
@@ -32039,7 +32130,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.28",
|
||||
"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: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",
|
||||
@@ -26,23 +28,30 @@
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/expo-two-way-audio": "0.1.28",
|
||||
"@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.28",
|
||||
"@getpaseo/server": "0.1.30",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/go": "^1.0.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/java": "^1.1.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/php": "^1.0.5",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@lezer/rust": "^1.0.2",
|
||||
"@lezer/xml": "^1.0.6",
|
||||
"@lezer/yaml": "^1.0.4",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
|
||||
@@ -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";
|
||||
@@ -67,6 +74,7 @@ import {
|
||||
} 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();
|
||||
@@ -379,6 +387,7 @@ function AppContainer({
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<KeyboardShortcutsDialog />
|
||||
@@ -499,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
|
||||
@@ -555,6 +584,7 @@ export default function RootLayout() {
|
||||
<GestureHandlerRootView
|
||||
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
|
||||
>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
@@ -578,14 +608,18 @@ 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 }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function HostNewAgentRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <DraftAgentScreen forcedServerId={serverId} />;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,5 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from './agent-status-bar.utils'
|
||||
import {
|
||||
getStatusSelectorHint,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from './agent-status-bar.utils'
|
||||
|
||||
describe('getStatusSelectorHint', () => {
|
||||
it('explains what each editable status control does', () => {
|
||||
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
|
||||
expect(getStatusSelectorHint('model')).toBe('Change model')
|
||||
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeModelId', () => {
|
||||
it('treats empty and default values as unset', () => {
|
||||
|
||||
@@ -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,22 +20,34 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from '@server/server/agent/agent-sdk-types'
|
||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
|
||||
import {
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
} from '@server/server/agent/provider-manifest'
|
||||
import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from '@/components/agent-status-bar.utils'
|
||||
|
||||
type StatusOption = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string
|
||||
providerOptions?: StatusOption[]
|
||||
selectedProviderId?: string
|
||||
onSelectProvider?: (providerId: string) => void
|
||||
@@ -53,6 +75,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 +97,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,
|
||||
@@ -91,7 +145,7 @@ function ControlledStatusBar({
|
||||
const { theme } = useUnistyles()
|
||||
const isWeb = Platform.OS === 'web'
|
||||
const [prefsOpen, setPrefsOpen] = useState(false)
|
||||
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null)
|
||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
|
||||
|
||||
const providerAnchorRef = useRef<View>(null)
|
||||
const modeAnchorRef = useRef<View>(null)
|
||||
@@ -113,6 +167,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,15 +203,39 @@ 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) => {
|
||||
(selector: StatusSelector) => (nextOpen: boolean) => {
|
||||
setOpenSelector(nextOpen ? selector : null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSelectorPress = useCallback(
|
||||
(selector: StatusSelector) => {
|
||||
handleOpenChange(selector)(openSelector !== selector)
|
||||
},
|
||||
[handleOpenChange, openSelector]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
<>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
@@ -161,7 +244,7 @@ function ControlledStatusBar({
|
||||
ref={providerAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectProvider}
|
||||
onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')}
|
||||
onPress={() => handleSelectorPress('provider')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
@@ -190,24 +273,39 @@ function ControlledStatusBar({
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent mode"
|
||||
testID="agent-mode-selector"
|
||||
<Tooltip
|
||||
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => handleSelectorPress('mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeIconBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select agent mode (${displayMode})`}
|
||||
testID="agent-mode-selector"
|
||||
>
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : (
|
||||
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxModeOptions}
|
||||
value={selectedModeId ?? ''}
|
||||
@@ -217,64 +315,90 @@ 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 ? (
|
||||
<>
|
||||
<Tooltip
|
||||
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => handleSelectorPress('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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select thinking option"
|
||||
testID="agent-thinking-selector"
|
||||
<Tooltip
|
||||
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<Brain
|
||||
size={theme.iconSize.xs}
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={{ marginTop: 1 }}
|
||||
/>
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
onPress={() => handleSelectorPress('thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select thinking option (${displayThinking})`}
|
||||
testID="agent-thinking-selector"
|
||||
>
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxThinkingOptions}
|
||||
value={selectedThinkingOptionId ?? ''}
|
||||
@@ -300,7 +424,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
|
||||
@@ -311,7 +435,10 @@ function ControlledStatusBar({
|
||||
>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'provider'}
|
||||
onOpenChange={handleOpenChange('provider')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectProvider}
|
||||
style={({ pressed }) => [
|
||||
@@ -343,7 +470,10 @@ function ControlledStatusBar({
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'mode'}
|
||||
onOpenChange={handleOpenChange('mode')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectMode}
|
||||
style={({ pressed }) => [
|
||||
@@ -355,17 +485,60 @@ 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
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
>
|
||||
<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,39 +546,12 @@ 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>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'thinking'}
|
||||
onOpenChange={handleOpenChange('thinking')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectThinking}
|
||||
style={({ pressed }) => [
|
||||
@@ -504,6 +650,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={agent.provider}
|
||||
modeOptions={
|
||||
modeOptions.length > 0
|
||||
? modeOptions
|
||||
@@ -555,33 +702,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 +730,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 +790,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,
|
||||
},
|
||||
@@ -639,9 +824,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
prefsButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
|
||||
|
||||
export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking'
|
||||
|
||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||
switch (selector) {
|
||||
case 'thinking':
|
||||
return 'Thinking mode'
|
||||
case 'model':
|
||||
return 'Change model'
|
||||
case 'mode':
|
||||
return 'Change permission mode'
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
|
||||
if (!normalized || normalized.toLowerCase() === 'default') {
|
||||
|
||||
@@ -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";
|
||||
@@ -70,6 +69,11 @@ 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) =>
|
||||
@@ -659,50 +663,58 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
});
|
||||
|
||||
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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -1086,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",
|
||||
@@ -1190,6 +1192,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
fileSectionHeaderContainer: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
fileSectionHeaderExpanded: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
fileSectionBodyContainer: {
|
||||
@@ -1208,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,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,8 @@ import Animated, {
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { usePanelStore } from '@/stores/panel-store'
|
||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||
|
||||
@@ -117,7 +119,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
serverId: activeServerId,
|
||||
enabled: isOpen,
|
||||
})
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
|
||||
useSidebarShortcutModel(projects)
|
||||
const {
|
||||
translateX,
|
||||
@@ -152,16 +154,16 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
closeToAgent()
|
||||
}, [closeToAgent])
|
||||
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen)
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId)
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
closeToAgent()
|
||||
setProjectPickerOpen(true)
|
||||
}, [closeToAgent, setProjectPickerOpen])
|
||||
void openProjectPicker()
|
||||
}, [closeToAgent, openProjectPicker])
|
||||
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
setProjectPickerOpen(true)
|
||||
}, [setProjectPickerOpen])
|
||||
void openProjectPicker()
|
||||
}, [openProjectPicker])
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
@@ -312,12 +314,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleOpenProjectMobile}
|
||||
testID="sidebar-sessions"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
@@ -327,7 +329,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
hovered && styles.newAgentButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
Add project
|
||||
Sessions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -343,6 +345,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
onSetProjectCollapsed={setProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
@@ -373,23 +376,33 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProjectMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
@@ -441,19 +454,19 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleOpenProjectDesktop}
|
||||
testID="sidebar-sessions"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
Add project
|
||||
Sessions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -469,6 +482,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
onSetProjectCollapsed={setProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
@@ -495,23 +509,33 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProjectDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
@@ -594,7 +618,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingRight: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[3],
|
||||
flexShrink: 0,
|
||||
},
|
||||
newAgentButtonHovered: {},
|
||||
@@ -690,4 +715,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -71,6 +71,10 @@ import {
|
||||
buildToolCallDisplayModel,
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
@@ -537,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,
|
||||
@@ -620,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],
|
||||
@@ -631,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",
|
||||
@@ -656,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,
|
||||
@@ -677,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,
|
||||
@@ -706,6 +715,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
pressableExpanded: {
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
@@ -748,11 +759,11 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
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(
|
||||
() => {
|
||||
@@ -937,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[],
|
||||
@@ -1590,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(
|
||||
@@ -1666,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) {
|
||||
@@ -1713,7 +1753,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={expandableBadgeStylesheet.secondaryLabel}
|
||||
style={secondaryLabelStyle}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
|
||||
>
|
||||
@@ -1808,7 +1848,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
{isInteractive && isHovered ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
color={theme.colors.foreground}
|
||||
style={chevronStyle}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1923,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) {
|
||||
@@ -1984,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}
|
||||
|
||||
@@ -11,21 +11,17 @@ import {
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
|
||||
@@ -42,15 +38,12 @@ export function ProjectPickerModal() {
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined
|
||||
);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.setHasHydratedWorkspaces
|
||||
);
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) return [];
|
||||
@@ -101,31 +94,15 @@ export function ProjectPickerModal() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = await client.openProject(trimmed);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
const didOpenProject = await openProject(trimmed);
|
||||
if (didOpenProject) {
|
||||
setOpen(false);
|
||||
}
|
||||
mergeWorkspaces(serverId, [
|
||||
normalizeWorkspaceDescriptor(payload.workspace),
|
||||
]);
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
setOpen(false);
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(
|
||||
serverId,
|
||||
payload.workspace.id,
|
||||
{ kind: "draft", draftId: "new" }
|
||||
) as any
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to open project"
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[client, mergeWorkspaces, serverId, setHasHydratedWorkspaces, setOpen, toast]
|
||||
[client, openProject, serverId, setOpen]
|
||||
);
|
||||
|
||||
const handleSubmitCustom = useCallback(() => {
|
||||
@@ -294,7 +271,7 @@ export function ProjectPickerModal() {
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{path}
|
||||
{shortenPath(path)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -32,8 +33,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt
|
||||
import { getIsTauri } from '@/constants/layout'
|
||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||
import {
|
||||
buildHostNewAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildHostWorkspaceRouteWithOpenIntent,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
import {
|
||||
@@ -70,6 +70,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
|
||||
import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store'
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store'
|
||||
import { createNameId } from 'mnemonic-id'
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -88,6 +91,7 @@ interface SidebarWorkspaceListProps {
|
||||
serverId: string | null
|
||||
collapsedProjectKeys: ReadonlySet<string>
|
||||
onToggleProjectCollapsed: (projectKey: string) => void
|
||||
onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||
isRefreshing?: boolean
|
||||
onRefresh?: () => void
|
||||
@@ -103,9 +107,11 @@ interface ProjectHeaderRowProps {
|
||||
iconDataUri: string | null
|
||||
workspace: SidebarWorkspaceEntry | null
|
||||
selected?: boolean
|
||||
chevron: 'expand' | 'collapse' | 'disclosure'
|
||||
chevron: 'expand' | 'collapse' | null
|
||||
onPress: () => void
|
||||
onCreateWorktree?: () => void
|
||||
shortcutNumber?: number | null
|
||||
showShortcutBadge?: boolean
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
isArchiving?: boolean
|
||||
@@ -122,6 +128,7 @@ interface WorkspaceRowInnerProps {
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
isArchiving: boolean
|
||||
isCreating?: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
menuController: ReturnType<typeof useContextMenu> | null
|
||||
archiveLabel?: string
|
||||
@@ -182,11 +189,15 @@ function ProjectLeadingVisual({
|
||||
displayName,
|
||||
iconDataUri,
|
||||
workspace,
|
||||
chevron = null,
|
||||
showChevron = false,
|
||||
isArchiving = false,
|
||||
}: {
|
||||
displayName: string
|
||||
iconDataUri: string | null
|
||||
workspace: SidebarWorkspaceEntry | null
|
||||
chevron?: 'expand' | 'collapse' | null
|
||||
showChevron?: boolean
|
||||
isArchiving?: boolean
|
||||
}) {
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName)
|
||||
@@ -197,7 +208,9 @@ function ProjectLeadingVisual({
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
{shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||
{showChevron && chevron !== null ? (
|
||||
<ProjectInlineChevron chevron={chevron} />
|
||||
) : shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
|
||||
) : iconDataUri ? (
|
||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||
@@ -213,8 +226,11 @@ function ProjectLeadingVisual({
|
||||
function ProjectInlineChevron({
|
||||
chevron,
|
||||
}: {
|
||||
chevron: 'expand' | 'collapse' | 'disclosure'
|
||||
chevron: 'expand' | 'collapse' | null
|
||||
}) {
|
||||
if (chevron === null) {
|
||||
return null
|
||||
}
|
||||
if (chevron === 'collapse') {
|
||||
return <ChevronDown size={14} color="#9ca3af" />
|
||||
}
|
||||
@@ -224,33 +240,47 @@ function ProjectInlineChevron({
|
||||
function NewWorktreeButton({
|
||||
displayName,
|
||||
onPress,
|
||||
visible,
|
||||
testID,
|
||||
}: {
|
||||
displayName: string
|
||||
onPress: () => void
|
||||
visible: boolean
|
||||
testID: string
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.projectIconActionButton,
|
||||
(hovered || pressed) && styles.projectIconActionButtonHovered,
|
||||
]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
onPress()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
<Plus size={14} color="#9ca3af" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? 'auto' : 'none'}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.projectIconActionButton,
|
||||
!visible && styles.projectIconActionButtonHidden,
|
||||
(hovered || pressed) && styles.projectIconActionButtonHovered,
|
||||
]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
onPress()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Plus
|
||||
size={14}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -477,12 +507,15 @@ function ProjectHeaderRow({
|
||||
chevron,
|
||||
onPress,
|
||||
onCreateWorktree,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving = false,
|
||||
menuController,
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -508,34 +541,71 @@ function ProjectHeaderRow({
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
/>
|
||||
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
|
||||
<ProjectInlineChevron chevron={chevron} />
|
||||
<View style={styles.projectTitleGroup}>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{onCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={onCreateWorktree}
|
||||
visible={isHovered}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
if (menuController) {
|
||||
return (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
<View
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
style={({ pressed }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.projectRowHovered,
|
||||
isHovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
@@ -545,27 +615,8 @@ function ProjectHeaderRow({
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -578,6 +629,7 @@ function WorkspaceRowInner({
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving,
|
||||
isCreating = false,
|
||||
dragHandleProps,
|
||||
menuController,
|
||||
archiveLabel,
|
||||
@@ -630,15 +682,23 @@ function WorkspaceRowInner({
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
loading={isArchiving || isCreating}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered]}
|
||||
style={[
|
||||
styles.workspaceBranchText,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isMobile) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -715,6 +775,7 @@ function WorkspaceRowWithMenu({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
isCreating = false,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
@@ -725,6 +786,7 @@ function WorkspaceRowWithMenu({
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
canCopyBranchName: boolean
|
||||
isCreating?: boolean
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||
@@ -825,6 +887,7 @@ function WorkspaceRowWithMenu({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
||||
@@ -844,6 +907,8 @@ function NonGitProjectRowWithMenuContent({
|
||||
workspace,
|
||||
selected,
|
||||
onPress,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
@@ -854,6 +919,8 @@ function NonGitProjectRowWithMenuContent({
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
@@ -907,8 +974,10 @@ function NonGitProjectRowWithMenuContent({
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
chevron="disclosure"
|
||||
chevron={null}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchivingWorkspace}
|
||||
@@ -942,6 +1011,8 @@ function NonGitProjectRowWithMenu(props: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
@@ -953,37 +1024,65 @@ function NonGitProjectRowWithMenu(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowPlain({
|
||||
workspace,
|
||||
selected,
|
||||
function FlattenedProjectRow({
|
||||
project,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
rowModel,
|
||||
onPress,
|
||||
onCreateWorktree,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
project: SidebarProjectEntry
|
||||
displayName: string
|
||||
iconDataUri: string | null
|
||||
rowModel: Extract<ReturnType<typeof buildSidebarProjectRowModel>, { kind: 'workspace_link' }>
|
||||
onPress: () => void
|
||||
onCreateWorktree?: () => void
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
if (project.projectKind === 'non_git') {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
selected={rowModel.selected}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
selected={rowModel.selected}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={onPress}
|
||||
onCreateWorktree={rowModel.trailingAction === 'new_worktree' ? onCreateWorktree : undefined}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={false}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -998,6 +1097,7 @@ function WorkspaceRow({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
isCreating = false,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
@@ -1008,6 +1108,7 @@ function WorkspaceRow({
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
canCopyBranchName: boolean
|
||||
isCreating?: boolean
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
@@ -1020,6 +1121,7 @@ function WorkspaceRow({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1042,6 +1144,7 @@ function ProjectBlock({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
creatingWorkspaceIds,
|
||||
}: {
|
||||
project: SidebarProjectEntry
|
||||
collapsed: boolean
|
||||
@@ -1060,6 +1163,7 @@ function ProjectBlock({
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
useNestable: boolean
|
||||
creatingWorkspaceIds: ReadonlySet<string>
|
||||
}) {
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
@@ -1071,11 +1175,9 @@ function ProjectBlock({
|
||||
}),
|
||||
[activeWorkspaceSelection, collapsed, project, serverId]
|
||||
)
|
||||
const flattenedWorkspace = rowModel.flattenedWorkspace
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps }) => {
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId)
|
||||
const isSelected =
|
||||
Boolean(serverId) &&
|
||||
activeWorkspaceSelection?.serverId === serverId &&
|
||||
@@ -1088,12 +1190,13 @@ function ProjectBlock({
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={project.projectKind === 'git'}
|
||||
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
}
|
||||
onWorkspacePress?.()
|
||||
router.replace(workspaceRoute as any)
|
||||
navigateToWorkspace(serverId, item.workspaceId)
|
||||
}}
|
||||
drag={input?.drag ?? (() => {})}
|
||||
isDragging={input?.isDragging ?? false}
|
||||
@@ -1103,6 +1206,8 @@ function ProjectBlock({
|
||||
},
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
project.projectKind,
|
||||
creatingWorkspaceIds,
|
||||
onWorkspacePress,
|
||||
serverId,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -1135,20 +1240,26 @@ function ProjectBlock({
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
{flattenedWorkspace ? (
|
||||
<NonGitProjectRowWithMenu
|
||||
{rowModel.kind === 'workspace_link' ? (
|
||||
<FlattenedProjectRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={flattenedWorkspace}
|
||||
selected={rowModel.selected}
|
||||
rowModel={rowModel}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
}
|
||||
onWorkspacePress?.()
|
||||
router.replace(buildHostWorkspaceRoute(serverId, flattenedWorkspace.workspaceId) as any)
|
||||
navigateToWorkspace(serverId, rowModel.workspace.workspaceId)
|
||||
}}
|
||||
onCreateWorktree={
|
||||
rowModel.trailingAction === 'new_worktree' && onCreateWorktree
|
||||
? () => onCreateWorktree(project)
|
||||
: undefined
|
||||
}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -1199,6 +1310,7 @@ export function SidebarWorkspaceList({
|
||||
serverId,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
onSetProjectCollapsed,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
isRefreshing = false,
|
||||
onRefresh,
|
||||
@@ -1209,6 +1321,14 @@ export function SidebarWorkspaceList({
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const isNative = Platform.OS !== 'web'
|
||||
const pathname = usePathname()
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
|
||||
const toast = useToast()
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces)
|
||||
const [creatingProjectKey, setCreatingProjectKey] = useState<string | null>(null)
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set())
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map()
|
||||
)
|
||||
const isTauri = getIsTauri()
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||
@@ -1219,19 +1339,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) {
|
||||
@@ -1299,6 +1408,51 @@ export function SidebarWorkspaceList({
|
||||
return byProject
|
||||
}, [projectIconQueries, projectIconRequests, projects, serverId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timeout of creatingWorkspaceTimeoutsRef.current.values()) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
creatingWorkspaceTimeoutsRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (creatingWorkspaceIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const visibleWorkspaceIds = new Set<string>()
|
||||
for (const project of projects) {
|
||||
for (const workspace of project.workspaces) {
|
||||
visibleWorkspaceIds.add(workspace.workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
const removedWorkspaceIds = Array.from(creatingWorkspaceIds).filter(
|
||||
(workspaceId) => !visibleWorkspaceIds.has(workspaceId)
|
||||
)
|
||||
if (removedWorkspaceIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const workspaceId of removedWorkspaceIds) {
|
||||
const timeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
creatingWorkspaceTimeoutsRef.current.delete(workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const workspaceId of removedWorkspaceIds) {
|
||||
next.delete(workspaceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [creatingWorkspaceIds, projects])
|
||||
|
||||
const handleProjectDragEnd = useCallback(
|
||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||
if (!serverId) {
|
||||
@@ -1357,18 +1511,68 @@ export function SidebarWorkspaceList({
|
||||
)
|
||||
|
||||
const handleCreateWorktree = useCallback(
|
||||
(project: SidebarProjectEntry) => {
|
||||
async (project: SidebarProjectEntry) => {
|
||||
if (!serverId || project.projectKind !== 'git') {
|
||||
return
|
||||
}
|
||||
router.push(
|
||||
buildHostNewAgentRoute(serverId, {
|
||||
workingDir: project.iconWorkingDir,
|
||||
worktreeMode: 'create',
|
||||
}) as any
|
||||
)
|
||||
if (creatingProjectKey) {
|
||||
return
|
||||
}
|
||||
onSetProjectCollapsed(project.projectKey, false)
|
||||
setCreatingProjectKey(project.projectKey)
|
||||
try {
|
||||
const client = getHostRuntimeStore().getClient(serverId)
|
||||
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
|
||||
throw new Error('Host is not connected')
|
||||
}
|
||||
const payload = await client.createPaseoWorktree({
|
||||
cwd: project.iconWorkingDir,
|
||||
worktreeSlug: createNameId(),
|
||||
})
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? 'Failed to create worktree')
|
||||
}
|
||||
const workspace = payload.workspace
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(workspace)])
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.add(workspace.id)
|
||||
return next
|
||||
})
|
||||
const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspace.id)
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout)
|
||||
}
|
||||
creatingWorkspaceTimeoutsRef.current.set(
|
||||
workspace.id,
|
||||
setTimeout(() => {
|
||||
creatingWorkspaceTimeoutsRef.current.delete(workspace.id)
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
if (!current.has(workspace.id)) {
|
||||
return current
|
||||
}
|
||||
const next = new Set(current)
|
||||
next.delete(workspace.id)
|
||||
return next
|
||||
})
|
||||
}, 3000)
|
||||
)
|
||||
onWorkspacePress?.()
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(serverId, workspace.id, {
|
||||
kind: 'draft',
|
||||
draftId: 'new',
|
||||
}) as any
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
} finally {
|
||||
setCreatingProjectKey((current) => (current === project.projectKey ? null : current))
|
||||
}
|
||||
},
|
||||
[serverId]
|
||||
[creatingProjectKey, mergeWorkspaces, onSetProjectCollapsed, onWorkspacePress, serverId, toast]
|
||||
)
|
||||
|
||||
const renderProject = useCallback(
|
||||
@@ -1380,7 +1584,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}
|
||||
@@ -1392,12 +1596,13 @@ export function SidebarWorkspaceList({
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
useNestable={isNative}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
collapsedProjectKeys,
|
||||
effectiveActiveWorkspaceSelection,
|
||||
handleCreateWorktree,
|
||||
handleWorkspaceReorder,
|
||||
onWorkspacePress,
|
||||
@@ -1408,6 +1613,7 @@ export function SidebarWorkspaceList({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
isNative,
|
||||
creatingWorkspaceIds,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1526,6 +1732,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectTitleGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectIcon: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -1554,8 +1767,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
projectActionButton: {
|
||||
flexDirection: 'row',
|
||||
@@ -1584,6 +1797,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectIconActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
projectIconActionButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectTrailingControlSlot: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectActionTooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -1631,7 +1854,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
workspaceRowContainer: {
|
||||
position: 'relative',
|
||||
@@ -1672,9 +1895,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceBranchTextCreating: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
workspaceBranchTextHovered: {
|
||||
opacity: 1,
|
||||
},
|
||||
workspaceCreatingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1705,11 +1936,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
shortcutBadgeText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
lineHeight: 14,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
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 {
|
||||
WorkspaceTabPresentationResolver,
|
||||
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();
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
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>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Platform,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
} from "@/constants/layout";
|
||||
|
||||
export type ToastVariant = "default" | "success" | "error";
|
||||
|
||||
export type ToastShowOptions = {
|
||||
icon?: ReactNode;
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
nativeAndroid?: boolean;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
export type ToastState = {
|
||||
id: number;
|
||||
content: ReactNode;
|
||||
nativeMessage: string | null;
|
||||
icon?: ReactNode;
|
||||
variant: ToastVariant;
|
||||
durationMs: number;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
export type ToastApi = {
|
||||
show: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
copied: (label?: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
type ToastViewportPlacement = "app-shell" | "panel";
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
|
||||
export function useToastHost(): {
|
||||
api: ToastApi;
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
} {
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const show = useCallback(
|
||||
(content: ReactNode, options?: ToastShowOptions) => {
|
||||
const nativeMessage =
|
||||
typeof content === "string"
|
||||
? content.trim()
|
||||
: null;
|
||||
if (!content || nativeMessage === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const nativeAndroid = options?.nativeAndroid ?? false;
|
||||
|
||||
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
|
||||
const duration =
|
||||
durationMs <= 2500
|
||||
? ToastAndroid.SHORT
|
||||
: ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(
|
||||
nativeMessage,
|
||||
duration,
|
||||
ToastAndroid.TOP
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
idRef.current += 1;
|
||||
setToast({
|
||||
id: idRef.current,
|
||||
content,
|
||||
nativeMessage,
|
||||
icon: options?.icon,
|
||||
variant,
|
||||
durationMs,
|
||||
testID: options?.testID,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
show,
|
||||
copied: (label?: string) =>
|
||||
show(label ? `Copied ${label}` : "Copied", {
|
||||
variant: "success",
|
||||
icon: <CheckCircle2 size={18} />,
|
||||
}),
|
||||
error: (message: string) =>
|
||||
show(message, { variant: "error", durationMs: 3200 }),
|
||||
}),
|
||||
[show]
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setToast(null);
|
||||
}, []);
|
||||
|
||||
return { api, toast, dismiss };
|
||||
}
|
||||
|
||||
export function ToastViewport({
|
||||
toast,
|
||||
onDismiss,
|
||||
placement = "app-shell",
|
||||
}: {
|
||||
toast: ToastState | null;
|
||||
onDismiss: () => void;
|
||||
placement?: ToastViewportPlacement;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(-8)).current;
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const animateOut = useCallback(() => {
|
||||
clearTimer();
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: -8,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) {
|
||||
onDismiss();
|
||||
}
|
||||
});
|
||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animateOut();
|
||||
}, toast.durationMs);
|
||||
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
||||
|
||||
if (!toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const topOffset =
|
||||
placement === "app-shell"
|
||||
? insets.top + headerTopPadding + headerHeight + theme.spacing[2]
|
||||
: theme.spacing[3];
|
||||
|
||||
const icon =
|
||||
toast.icon ?? (
|
||||
toast.variant === "success" ? (
|
||||
<CheckCircle2 size={18} color={theme.colors.primary} />
|
||||
) : toast.variant === "error" ? (
|
||||
<AlertTriangle size={18} color={theme.colors.destructive} />
|
||||
) : null
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "success" ? styles.toastSuccess : null,
|
||||
toast.variant === "error" ? styles.toastError : null,
|
||||
{
|
||||
marginTop: topOffset,
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
]}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text
|
||||
testID="app-toast-message"
|
||||
style={[
|
||||
styles.message,
|
||||
toast.variant === "error" ? styles.messageError : null,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (
|
||||
placement === "app-shell" &&
|
||||
Platform.OS === "web" &&
|
||||
typeof document !== "undefined"
|
||||
) {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
top: 0,
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
alignSelf: "center",
|
||||
maxWidth: "92%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
toastSuccess: {
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
toastError: {
|
||||
borderColor: theme.colors.destructive,
|
||||
},
|
||||
iconSlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
contentSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
message: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
messageError: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
|
||||
@@ -240,9 +243,79 @@ export function ToolCallDetailsContent({
|
||||
);
|
||||
}
|
||||
} else if (detail?.type === "search") {
|
||||
const searchSections: ReactNode[] = [];
|
||||
if (detail.query) {
|
||||
searchSections.push(
|
||||
<View key="search-query" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.query}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.content) {
|
||||
searchSections.push(
|
||||
<View key="search-content" style={styles.section}>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText}>{detail.content}</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.filePaths && detail.filePaths.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-files" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.filePaths.join("\n")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.webResults && detail.webResults.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-web-results" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.annotations && detail.annotations.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-annotations" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.annotations.join("\n\n")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
sections.push(...searchSections);
|
||||
} else if (detail?.type === "fetch") {
|
||||
sections.push(
|
||||
<View key="search" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.query}</Text>
|
||||
<View
|
||||
key="fetch"
|
||||
style={[styles.section, shouldFill && styles.fillHeight]}
|
||||
>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
} else if (detail?.type === "plain_text") {
|
||||
@@ -269,7 +342,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 +404,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 +562,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 {
|
||||
@@ -131,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);
|
||||
|
||||
@@ -159,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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,62 +1,9 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Platform,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react-native";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
} from "@/constants/layout";
|
||||
|
||||
type ToastVariant = "default" | "success" | "error";
|
||||
|
||||
export type ToastShowOptions = {
|
||||
icon?: ReactNode;
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
/**
|
||||
* Set to true to use OS toast on Android.
|
||||
*/
|
||||
nativeAndroid?: boolean;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
type ToastState = {
|
||||
id: number;
|
||||
content: ReactNode;
|
||||
nativeMessage: string | null;
|
||||
icon?: ReactNode;
|
||||
variant: ToastVariant;
|
||||
durationMs: number;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
export type ToastApi = {
|
||||
show: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
copied: (label?: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
ToastViewport,
|
||||
useToastHost,
|
||||
type ToastApi,
|
||||
} from "@/components/toast-host";
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
@@ -69,259 +16,12 @@ export function useToast(): ToastApi {
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const show = useCallback(
|
||||
(content: ReactNode, options?: ToastShowOptions) => {
|
||||
const nativeMessage =
|
||||
typeof content === "string"
|
||||
? content.trim()
|
||||
: null;
|
||||
if (!content || nativeMessage === "") return;
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const nativeAndroid = options?.nativeAndroid ?? false;
|
||||
|
||||
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
|
||||
const duration =
|
||||
durationMs <= 2500
|
||||
? ToastAndroid.SHORT
|
||||
: ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(
|
||||
nativeMessage,
|
||||
duration,
|
||||
ToastAndroid.TOP
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
idRef.current += 1;
|
||||
setToast({
|
||||
id: idRef.current,
|
||||
content,
|
||||
nativeMessage,
|
||||
icon: options?.icon,
|
||||
variant,
|
||||
durationMs,
|
||||
testID: options?.testID,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
show,
|
||||
copied: (label?: string) =>
|
||||
show(label ? `Copied ${label}` : "Copied", {
|
||||
variant: "success",
|
||||
icon: <CheckCircle2 size={18} />,
|
||||
}),
|
||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
||||
}),
|
||||
[show]
|
||||
);
|
||||
const { api, toast, dismiss } = useToastHost();
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<ToastViewport toast={toast} onDismiss={() => setToast(null)} />
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastViewport({
|
||||
toast,
|
||||
onDismiss,
|
||||
}: {
|
||||
toast: ToastState | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(-8)).current;
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const animateOut = useCallback(() => {
|
||||
clearTimer();
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: -8,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) {
|
||||
onDismiss();
|
||||
}
|
||||
});
|
||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animateOut();
|
||||
}, toast.durationMs);
|
||||
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
||||
|
||||
if (!toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
|
||||
const icon =
|
||||
toast.icon ?? (
|
||||
toast.variant === "success" ? (
|
||||
<CheckCircle2 size={18} color={theme.colors.primary} />
|
||||
) : toast.variant === "error" ? (
|
||||
<AlertTriangle size={18} color={theme.colors.destructive} />
|
||||
) : null
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "success" ? styles.toastSuccess : null,
|
||||
toast.variant === "error" ? styles.toastError : null,
|
||||
{
|
||||
marginTop:
|
||||
insets.top + headerTopPadding + headerHeight + theme.spacing[2],
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
]}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text
|
||||
testID="app-toast-message"
|
||||
style={[
|
||||
styles.message,
|
||||
toast.variant === "error" ? styles.messageError : null,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// On web, portal to overlay root to control stacking order
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
top: 0,
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
alignSelf: "center",
|
||||
maxWidth: "92%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
toastSuccess: {
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
toastError: {
|
||||
borderColor: theme.colors.destructive,
|
||||
},
|
||||
iconSlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
contentSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
message: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
messageError: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface DesktopPermissionRowProps {
|
||||
isRequesting: boolean;
|
||||
showBorder?: boolean;
|
||||
onRequest: () => void;
|
||||
extraActionLabel?: string;
|
||||
isExtraActionBusy?: boolean;
|
||||
isExtraActionDisabled?: boolean;
|
||||
onExtraAction?: () => void;
|
||||
}
|
||||
|
||||
export function DesktopPermissionRow({
|
||||
@@ -18,6 +22,10 @@ export function DesktopPermissionRow({
|
||||
isRequesting,
|
||||
showBorder,
|
||||
onRequest,
|
||||
extraActionLabel,
|
||||
isExtraActionBusy = false,
|
||||
isExtraActionDisabled = false,
|
||||
onExtraAction,
|
||||
}: DesktopPermissionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const state = status?.state ?? "unknown";
|
||||
@@ -36,9 +44,21 @@ export function DesktopPermissionRow({
|
||||
</View>
|
||||
<View style={styles.permissionRowActions}>
|
||||
{isGranted ? (
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
<View style={styles.permissionGrantedActions}>
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
</View>
|
||||
{extraActionLabel && onExtraAction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={onExtraAction}
|
||||
disabled={isExtraActionDisabled || isExtraActionBusy}
|
||||
>
|
||||
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||
@@ -75,6 +95,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
permissionGrantedActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
permissionStatusPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -13,8 +13,10 @@ export function DesktopPermissionsSection() {
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
isSendingTestNotification,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
} = useDesktopPermissions();
|
||||
|
||||
if (!isDesktop) {
|
||||
@@ -22,6 +24,7 @@ export function DesktopPermissionsSection() {
|
||||
}
|
||||
|
||||
const isBusy = isRefreshing || requestingPermission !== null;
|
||||
const notificationsGranted = snapshot?.notifications.state === "granted";
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
@@ -48,6 +51,12 @@ export function DesktopPermissionsSection() {
|
||||
onRequest={() => {
|
||||
void requestPermission("notifications");
|
||||
}}
|
||||
extraActionLabel="Test"
|
||||
isExtraActionBusy={isSendingTestNotification}
|
||||
isExtraActionDisabled={!notificationsGranted || isBusy}
|
||||
onExtraAction={() => {
|
||||
void sendTestNotification();
|
||||
}}
|
||||
/>
|
||||
<DesktopPermissionRow
|
||||
title="Microphone"
|
||||
|
||||
@@ -38,9 +38,13 @@ import {
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
appVersion: string | null
|
||||
showLifecycleControls: boolean
|
||||
}
|
||||
|
||||
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
export function LocalDaemonSection({
|
||||
appVersion,
|
||||
showLifecycleControls,
|
||||
}: LocalDaemonSectionProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const showSection = shouldUseManagedDesktopDaemon()
|
||||
const { settings, updateSettings } = useAppSettings()
|
||||
@@ -347,57 +351,61 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
{isDaemonManagementPaused
|
||||
? 'Paused. The built-in daemon stays stopped until you start it again.'
|
||||
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? 'Resuming...'
|
||||
: 'Pausing...'
|
||||
: isDaemonManagementPaused
|
||||
? 'Resume'
|
||||
: 'Pause'}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? managedStatus?.status === 'running'
|
||||
? 'Restarting...'
|
||||
: 'Starting...'
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
{isDaemonManagementPaused
|
||||
? 'Paused. The built-in daemon stays stopped until you start it again.'
|
||||
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? 'Resuming...'
|
||||
: 'Pausing...'
|
||||
: isDaemonManagementPaused
|
||||
? 'Resume'
|
||||
: 'Pause'}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? managedStatus?.status === 'running'
|
||||
? 'Restarting...'
|
||||
: 'Starting...'
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
type DesktopPermissionKind,
|
||||
type DesktopPermissionSnapshot,
|
||||
} from "@/desktop/permissions/desktop-permissions";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
|
||||
export interface UseDesktopPermissionsReturn {
|
||||
isDesktop: boolean;
|
||||
snapshot: DesktopPermissionSnapshot | null;
|
||||
isRefreshing: boolean;
|
||||
requestingPermission: DesktopPermissionKind | null;
|
||||
isSendingTestNotification: boolean;
|
||||
refreshPermissions: () => Promise<void>;
|
||||
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
||||
sendTestNotification: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY_NOTIFICATION_STATUS = {
|
||||
@@ -34,6 +37,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
|
||||
null
|
||||
);
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -109,6 +113,29 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
[isDesktop, refreshPermissions]
|
||||
);
|
||||
|
||||
const sendTestNotification = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingTestNotification(true);
|
||||
try {
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, delivery works. Click it to verify the open flow.",
|
||||
});
|
||||
if (!sent) {
|
||||
console.warn("[Settings] Desktop test notification was not delivered");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to send desktop test notification", error);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsSendingTestNotification(false);
|
||||
}
|
||||
}
|
||||
}, [isDesktop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) {
|
||||
return;
|
||||
@@ -122,7 +149,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
isSendingTestNotification,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getTauri } from '@/utils/tauri'
|
||||
|
||||
export async function pickDirectory(): Promise<string | null> {
|
||||
const open = getTauri()?.dialog?.open
|
||||
if (typeof open !== 'function') {
|
||||
throw new Error('Tauri dialog open() is unavailable in this environment.')
|
||||
}
|
||||
|
||||
const selection = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
if (selection === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof selection === 'string') {
|
||||
return selection
|
||||
}
|
||||
|
||||
throw new Error('Unexpected directory picker response.')
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
export function UpdateBanner() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktop,
|
||||
status,
|
||||
availableUpdate,
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
isInstalling,
|
||||
} = useDesktopAppUpdater();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
void checkForUpdates({ silent: true });
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
void checkForUpdates({ silent: true });
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isDesktop, checkForUpdates]);
|
||||
|
||||
if (!isDesktop) return null;
|
||||
if (dismissed) return null;
|
||||
if (status !== "available" && status !== "installed") return null;
|
||||
|
||||
const isInstalled = status === "installed";
|
||||
|
||||
return (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<View style={styles.banner}>
|
||||
<Pressable
|
||||
onPress={() => setDismissed(true)}
|
||||
hitSlop={8}
|
||||
style={styles.closeButton}
|
||||
>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.textSection}>
|
||||
<Text style={styles.title}>
|
||||
{isInstalled ? "Update installed" : "Update available"}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{isInstalled
|
||||
? "Restart to use the new version."
|
||||
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
onPress={() => void openExternalUrl(CHANGELOG_URL)}
|
||||
style={({ pressed }) => [
|
||||
styles.outlineButton,
|
||||
pressed && styles.buttonPressed,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.outlineButtonText}>What's new</Text>
|
||||
</Pressable>
|
||||
|
||||
{!isInstalled && (
|
||||
<Pressable
|
||||
onPress={() => void installUpdate()}
|
||||
disabled={isInstalling}
|
||||
style={({ pressed }) => [
|
||||
styles.primaryButton,
|
||||
pressed && styles.buttonPressed,
|
||||
isInstalling && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>
|
||||
{isInstalling ? "Installing..." : "Install & restart"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "absolute",
|
||||
bottom: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
zIndex: 1000,
|
||||
},
|
||||
banner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[4],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[4],
|
||||
paddingRight: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
maxWidth: 480,
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
left: theme.spacing[2],
|
||||
padding: theme.spacing[1],
|
||||
zIndex: 1,
|
||||
},
|
||||
textSection: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
paddingTop: theme.spacing[1],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
subtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
alignItems: "center",
|
||||
},
|
||||
outlineButton: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
outlineButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
primaryButton: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.foreground,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: theme.colors.surface0,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
@@ -81,10 +81,13 @@ type UseAgentFormStateResult = {
|
||||
agentDefinition?: AgentProviderDefinition;
|
||||
modeOptions: AgentMode[];
|
||||
availableModels: AgentModelDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
isAllModelsLoading: boolean;
|
||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
isModelLoading: boolean;
|
||||
modelError: string | null;
|
||||
refreshProviderModels: () => void;
|
||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||
workingDirIsEmpty: boolean;
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
};
|
||||
@@ -440,6 +443,45 @@ export function useAgentFormState(
|
||||
|
||||
const availableModels = providerModelsQuery.data ?? null;
|
||||
|
||||
const allProviderModelQueries = useQueries({
|
||||
queries: providerDefinitions.map((def) => ({
|
||||
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
|
||||
enabled: Boolean(
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(def.id as AgentProvider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const allProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (let i = 0; i < providerDefinitions.length; i++) {
|
||||
const query = allProviderModelQueries[i];
|
||||
if (query?.data) {
|
||||
map.set(providerDefinitions[i]!.id, query.data);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allProviderModelQueries, providerDefinitions]);
|
||||
|
||||
const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading);
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
return combineInitialValues(initialValues, initialServerId);
|
||||
@@ -568,6 +610,25 @@ export function useAgentFormState(
|
||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences]
|
||||
);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string) => {
|
||||
const providerDef = providerDefinitionMap.get(provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: modelId,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
||||
}));
|
||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||
void updatePreferences({ provider });
|
||||
void updateProviderPreferences(provider, { model: modelId });
|
||||
},
|
||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences, updateProviderPreferences]
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
setFormState((prev) => ({ ...prev, modeId }));
|
||||
@@ -679,10 +740,13 @@ export function useAgentFormState(
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels: availableModels ?? [],
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
setProviderAndModelFromUser,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
}),
|
||||
@@ -706,10 +770,13 @@ export function useAgentFormState(
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
setProviderAndModelFromUser,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
]
|
||||
|
||||
@@ -6,12 +6,12 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
|
||||
const EMPTY_AGENTS: AggregatedAgent[] = [];
|
||||
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
|
||||
const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = [];
|
||||
|
||||
function isMatch(agent: AggregatedAgent, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
@@ -50,7 +54,7 @@ type CommandCenterActionDefinition = {
|
||||
icon?: "plus" | "settings";
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
keywords: string[];
|
||||
buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href;
|
||||
routeKind: "settings" | "none";
|
||||
};
|
||||
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
@@ -60,14 +64,14 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
icon: "plus",
|
||||
shortcutKeys: ["mod", "shift", "O"],
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
||||
routeKind: "none",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
icon: "settings",
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
buildRoute: ({ settingsRoute }) => settingsRoute,
|
||||
routeKind: "settings",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -88,7 +92,7 @@ export type CommandCenterActionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
route: Href;
|
||||
route?: Href;
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
};
|
||||
|
||||
@@ -118,6 +122,9 @@ export function useCommandCenter() {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const activeServerId = useMemo(() => {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
const serverIdFromPath = parseServerIdFromPathname(pathname);
|
||||
if (serverIdFromPath) {
|
||||
const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath);
|
||||
@@ -126,22 +133,20 @@ export function useCommandCenter() {
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, pathname]);
|
||||
}, [daemons, open, pathname]);
|
||||
|
||||
const { agents } = useAllAgentsList({
|
||||
serverId: activeServerId,
|
||||
});
|
||||
|
||||
const agentResults = useMemo(() => {
|
||||
if (!open || agents.length === 0) {
|
||||
return EMPTY_AGENTS;
|
||||
}
|
||||
const filtered = agents.filter((agent) => isMatch(agent, query));
|
||||
filtered.sort(sortAgents);
|
||||
return filtered;
|
||||
}, [agents, query]);
|
||||
|
||||
const newAgentRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
|
||||
}, [activeServerId]);
|
||||
}, [agents, open, query]);
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath = activeServerId;
|
||||
@@ -149,6 +154,9 @@ export function useCommandCenter() {
|
||||
}, [activeServerId]);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
if (!open) {
|
||||
return EMPTY_ACTION_ITEMS;
|
||||
}
|
||||
return COMMAND_CENTER_ACTIONS.filter((action) =>
|
||||
matchesActionQuery(query, action)
|
||||
).map<CommandCenterActionItem>((action) => ({
|
||||
@@ -156,12 +164,15 @@ export function useCommandCenter() {
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.buildRoute({ newAgentRoute, settingsRoute }),
|
||||
route: action.routeKind === "settings" ? settingsRoute : undefined,
|
||||
shortcutKeys: action.shortcutKeys,
|
||||
}));
|
||||
}, [newAgentRoute, query, settingsRoute]);
|
||||
}, [open, query, settingsRoute]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) {
|
||||
return EMPTY_COMMAND_CENTER_ITEMS;
|
||||
}
|
||||
const next: CommandCenterItem[] = [];
|
||||
for (const action of actionItems) {
|
||||
next.push({
|
||||
@@ -176,7 +187,7 @@ export function useCommandCenter() {
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}, [actionItems, agentResults]);
|
||||
}, [actionItems, agentResults, open]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
@@ -201,18 +212,21 @@ export function useCommandCenter() {
|
||||
[pathname, setOpen]
|
||||
);
|
||||
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||
|
||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
if (action.id === "new-agent") {
|
||||
setProjectPickerOpen(true);
|
||||
void openProjectPicker();
|
||||
return;
|
||||
}
|
||||
if (!action.route) {
|
||||
return;
|
||||
}
|
||||
didNavigateRef.current = true;
|
||||
router.push(action.route);
|
||||
}, [setOpen, setProjectPickerOpen]);
|
||||
}, [openProjectPicker, setOpen]);
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: CommandCenterItem) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { ToastShowOptions } from "@/components/toast-host";
|
||||
|
||||
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
|
||||
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
|
||||
@@ -8,22 +8,23 @@ const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
|
||||
interface UseDelayedHistoryRefreshToastParams {
|
||||
isCatchingUp: boolean;
|
||||
indicatorColor: string;
|
||||
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
}
|
||||
|
||||
export function useDelayedHistoryRefreshToast({
|
||||
isCatchingUp,
|
||||
indicatorColor,
|
||||
showToast,
|
||||
}: UseDelayedHistoryRefreshToastParams): void {
|
||||
const toast = useToast();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wasCatchingUpRef = useRef(false);
|
||||
const isCatchingUpRef = useRef(false);
|
||||
const toastRef = useRef(toast);
|
||||
const showToastRef = useRef(showToast);
|
||||
const indicatorColorRef = useRef(indicatorColor);
|
||||
|
||||
useEffect(() => {
|
||||
toastRef.current = toast;
|
||||
}, [toast]);
|
||||
showToastRef.current = showToast;
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
indicatorColorRef.current = indicatorColor;
|
||||
@@ -44,7 +45,7 @@ export function useDelayedHistoryRefreshToast({
|
||||
if (!isCatchingUpRef.current) {
|
||||
return;
|
||||
}
|
||||
toastRef.current.show("Refreshing", {
|
||||
showToastRef.current("Refreshing", {
|
||||
icon: (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getManagedDaemonStatus } from '@/desktop/managed-runtime/managed-runtime'
|
||||
import { isTauriEnvironment } from '@/utils/tauri'
|
||||
|
||||
const MANAGED_DAEMON_SERVER_ID_QUERY_KEY = ['managed-daemon-server-id'] as const
|
||||
|
||||
interface ManagedDaemonServerIdResult {
|
||||
serverId: string | null
|
||||
}
|
||||
|
||||
async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult> {
|
||||
const status = await getManagedDaemonStatus()
|
||||
const serverId = status.serverId.trim()
|
||||
return {
|
||||
serverId: serverId.length > 0 ? serverId : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useIsLocalDaemon(serverId: string): boolean {
|
||||
const normalizedServerId = serverId.trim()
|
||||
const isDesktop = isTauriEnvironment()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: MANAGED_DAEMON_SERVER_ID_QUERY_KEY,
|
||||
queryFn: loadManagedDaemonServerId,
|
||||
enabled: isDesktop,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (!isDesktop || normalizedServerId.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return query.data?.serverId === normalizedServerId
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { usePathname } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import {
|
||||
type MessageInputKeyboardActionKind,
|
||||
type KeyboardShortcutPayload,
|
||||
@@ -18,6 +20,7 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"
|
||||
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
@@ -32,9 +35,15 @@ export function useKeyboardShortcuts({
|
||||
selectedAgentId?: string;
|
||||
toggleFileExplorer?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
|
||||
const activeServerId =
|
||||
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
|
||||
hosts[0]?.serverId ??
|
||||
null;
|
||||
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -57,11 +66,7 @@ export function useKeyboardShortcuts({
|
||||
return false;
|
||||
}
|
||||
|
||||
const shouldReplace =
|
||||
Boolean(parseHostWorkspaceRouteFromPathname(pathname)) ||
|
||||
Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
navigate(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
|
||||
navigateToWorkspace(target.serverId, target.workspaceId);
|
||||
return true;
|
||||
};
|
||||
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
||||
@@ -77,7 +82,7 @@ export function useKeyboardShortcuts({
|
||||
if (!fallback) {
|
||||
return false;
|
||||
}
|
||||
router.push(buildHostWorkspaceRoute(fallback.serverId, fallback.workspaceId) as any);
|
||||
navigateToWorkspace(fallback.serverId, fallback.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -92,12 +97,12 @@ export function useKeyboardShortcuts({
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
router.replace(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
|
||||
navigateToWorkspace(target.serverId, target.workspaceId);
|
||||
return true;
|
||||
};
|
||||
|
||||
const openProjectPicker = (): boolean => {
|
||||
useKeyboardShortcutsStore.getState().setProjectPickerOpen(true);
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -134,22 +139,6 @@ export function useKeyboardShortcuts({
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const requestWorkspaceTabAction = (input:
|
||||
| { kind: "new" | "close-current" }
|
||||
| { kind: "navigate-index"; index: number }
|
||||
| { kind: "navigate-relative"; delta: 1 | -1 }): boolean => {
|
||||
const route = parseHostWorkspaceRouteFromPathname(pathname);
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
useKeyboardShortcutsStore.getState().requestWorkspaceTabAction({
|
||||
serverId: route.serverId,
|
||||
workspaceId: route.workspaceId,
|
||||
...input,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAction = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
@@ -159,25 +148,48 @@ export function useKeyboardShortcuts({
|
||||
case "agent.new":
|
||||
return openProjectPicker();
|
||||
case "workspace.tab.new":
|
||||
return requestWorkspaceTabAction({ kind: "new" });
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.tab.new",
|
||||
scope: "workspace",
|
||||
});
|
||||
case "workspace.tab.close.current":
|
||||
return requestWorkspaceTabAction({ kind: "close-current" });
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.tab.close-current",
|
||||
scope: "workspace",
|
||||
});
|
||||
case "workspace.tab.navigate.index":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
|
||||
return false;
|
||||
}
|
||||
return requestWorkspaceTabAction({
|
||||
kind: "navigate-index",
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.tab.navigate-index",
|
||||
scope: "workspace",
|
||||
index: input.payload.index,
|
||||
});
|
||||
case "workspace.tab.navigate.relative":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("delta" in input.payload)) {
|
||||
return false;
|
||||
}
|
||||
return requestWorkspaceTabAction({
|
||||
kind: "navigate-relative",
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.tab.navigate-relative",
|
||||
scope: "workspace",
|
||||
delta: input.payload.delta,
|
||||
});
|
||||
case "workspace.pane.split.right":
|
||||
case "workspace.pane.split.down":
|
||||
case "workspace.pane.focus.left":
|
||||
case "workspace.pane.focus.right":
|
||||
case "workspace.pane.focus.up":
|
||||
case "workspace.pane.focus.down":
|
||||
case "workspace.pane.move-tab.left":
|
||||
case "workspace.pane.move-tab.right":
|
||||
case "workspace.pane.move-tab.up":
|
||||
case "workspace.pane.move-tab.down":
|
||||
case "workspace.pane.close":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: input.action,
|
||||
scope: "workspace",
|
||||
});
|
||||
case "workspace.navigate.index":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
|
||||
return false;
|
||||
@@ -324,9 +336,9 @@ export function useKeyboardShortcuts({
|
||||
}, [
|
||||
enabled,
|
||||
isMobile,
|
||||
openProjectPickerAction,
|
||||
pathname,
|
||||
resetModifiers,
|
||||
router,
|
||||
selectedAgentId,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from "react";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useIsLocalDaemon } from "./use-is-local-daemon";
|
||||
import { useOpenProject } from "./use-open-project";
|
||||
|
||||
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore(
|
||||
(state) => state.setProjectPickerOpen
|
||||
);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
return useCallback(async () => {
|
||||
if (!normalizedServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLocalDaemon) {
|
||||
setProjectPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const path = await pickDirectory();
|
||||
if (path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openProject(path);
|
||||
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { router } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||
|
||||
export function useOpenProject(
|
||||
serverId: string | null
|
||||
): (path: string) => Promise<boolean> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const toast = useToast();
|
||||
const { client } = useHostRuntimeSession(normalizedServerId);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.setHasHydratedWorkspaces
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
async (path: string) => {
|
||||
const trimmedPath = path.trim();
|
||||
if (!trimmedPath || !client || !normalizedServerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.openProject(trimmedPath);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
}
|
||||
|
||||
mergeWorkspaces(normalizedServerId, [
|
||||
normalizeWorkspaceDescriptor(payload.workspace),
|
||||
]);
|
||||
setHasHydratedWorkspaces(normalizedServerId, true);
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(
|
||||
normalizedServerId,
|
||||
payload.workspace.id,
|
||||
{ kind: "draft", draftId: "new" }
|
||||
) as any
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to open project"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
client,
|
||||
mergeWorkspaces,
|
||||
normalizedServerId,
|
||||
setHasHydratedWorkspaces,
|
||||
toast,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
||||
import { isSidebarProjectFlattened } from '@/utils/sidebar-project-row-model'
|
||||
|
||||
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||
@@ -23,10 +24,14 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
|
||||
const collapsibleProjectKeys = new Set(
|
||||
projects
|
||||
.filter((project) => !isSidebarProjectFlattened(project))
|
||||
.map((project) => project.projectKey)
|
||||
)
|
||||
const next = new Set<string>()
|
||||
for (const key of prev) {
|
||||
if (validProjectKeys.has(key)) {
|
||||
if (collapsibleProjectKeys.has(key)) {
|
||||
next.add(key)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +68,22 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (collapsed) {
|
||||
next.add(projectKey)
|
||||
} else {
|
||||
next.delete(projectKey)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
|
||||
setProjectCollapsed,
|
||||
toggleProjectCollapsed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
/**
|
||||
* Navigate to a workspace screen. Uses router.navigate() which,
|
||||
* combined with getId on the workspace Stack.Screen, ensures:
|
||||
* - Only one instance of each workspace exists in the stack
|
||||
* - History is preserved (back button works)
|
||||
* - No duplicate workspace screens
|
||||
*/
|
||||
export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
||||
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
router.navigate(href as any);
|
||||
}
|
||||
|
||||
export function useWorkspaceNavigation() {
|
||||
return {
|
||||
navigateToWorkspace: useCallback(
|
||||
(serverId: string, workspaceId: string) => {
|
||||
navigateToWorkspace(serverId, workspaceId);
|
||||
},
|
||||
[]
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,17 @@ export type KeyboardActionId =
|
||||
| "workspace.tab.close.current"
|
||||
| "workspace.tab.navigate.index"
|
||||
| "workspace.tab.navigate.relative"
|
||||
| "workspace.pane.split.right"
|
||||
| "workspace.pane.split.down"
|
||||
| "workspace.pane.focus.left"
|
||||
| "workspace.pane.focus.right"
|
||||
| "workspace.pane.focus.up"
|
||||
| "workspace.pane.focus.down"
|
||||
| "workspace.pane.move-tab.left"
|
||||
| "workspace.pane.move-tab.right"
|
||||
| "workspace.pane.move-tab.up"
|
||||
| "workspace.pane.move-tab.down"
|
||||
| "workspace.pane.close"
|
||||
| "workspace.navigate.index"
|
||||
| "workspace.navigate.relative"
|
||||
| "sidebar.toggle.left"
|
||||
|
||||
@@ -9,12 +9,44 @@ export type KeyboardActionId =
|
||||
| "message-input.dictation-toggle"
|
||||
| "message-input.dictation-cancel"
|
||||
| "message-input.voice-toggle"
|
||||
| "message-input.voice-mute-toggle";
|
||||
| "message-input.voice-mute-toggle"
|
||||
| "workspace.tab.new"
|
||||
| "workspace.tab.close-current"
|
||||
| "workspace.tab.navigate-index"
|
||||
| "workspace.tab.navigate-relative"
|
||||
| "workspace.pane.split.right"
|
||||
| "workspace.pane.split.down"
|
||||
| "workspace.pane.focus.left"
|
||||
| "workspace.pane.focus.right"
|
||||
| "workspace.pane.focus.up"
|
||||
| "workspace.pane.focus.down"
|
||||
| "workspace.pane.move-tab.left"
|
||||
| "workspace.pane.move-tab.right"
|
||||
| "workspace.pane.move-tab.up"
|
||||
| "workspace.pane.move-tab.down"
|
||||
| "workspace.pane.close";
|
||||
|
||||
export type KeyboardActionDefinition = {
|
||||
id: KeyboardActionId;
|
||||
scope: KeyboardActionScope;
|
||||
};
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-cancel"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
||||
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
|
||||
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.focus.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.focus.up"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.focus.down"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.left"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope };
|
||||
|
||||
export type KeyboardActionHandler = {
|
||||
handlerId: string;
|
||||
|
||||
@@ -169,6 +169,42 @@ describe("keyboard-shortcuts", () => {
|
||||
context: { isMac: true, isTauri: true },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Backslash to split pane right on macOS",
|
||||
event: { key: "\\", code: "Backslash", metaKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.split.right",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Shift+Backslash to split pane down on macOS",
|
||||
event: { key: "|", code: "Backslash", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.split.down",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Shift+ArrowRight to focus pane right on macOS",
|
||||
event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.focus.right",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Shift+Alt+ArrowDown to move tab down on macOS",
|
||||
event: {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
altKey: true,
|
||||
},
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.move-tab.down",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Shift+W to close pane on macOS",
|
||||
event: { key: "W", code: "KeyW", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.close",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+B sidebar toggle on macOS",
|
||||
event: { key: "b", code: "KeyB", metaKey: true },
|
||||
@@ -242,6 +278,11 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "d", code: "KeyD", metaKey: true },
|
||||
context: { isMac: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not bind pane shortcuts on non-mac platforms",
|
||||
event: { key: "\\", code: "Backslash", ctrlKey: true },
|
||||
context: { isMac: false },
|
||||
},
|
||||
{
|
||||
name: "keeps space typing available in message input",
|
||||
event: { key: " ", code: "Space" },
|
||||
@@ -278,6 +319,8 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
|
||||
"workspace-tab-close-current": ["alt", "shift", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -289,6 +332,8 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-close-current": ["mod", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -71,6 +71,10 @@ function isMod(event: KeyboardEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
function isMacCommand(event: KeyboardEvent): boolean {
|
||||
return event.metaKey && !event.ctrlKey;
|
||||
}
|
||||
|
||||
function parseDigit(event: KeyboardEvent): number | null {
|
||||
const code = event.code ?? "";
|
||||
if (code.startsWith("Digit")) {
|
||||
@@ -373,6 +377,204 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
keys: ["alt", "shift", "]"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-split-right-cmd-backslash",
|
||||
action: "workspace.pane.split.right",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.code === "Backslash",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-split-right",
|
||||
section: "global",
|
||||
label: "Split pane right",
|
||||
keys: ["mod", "\\"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-split-down-cmd-shift-backslash",
|
||||
action: "workspace.pane.split.down",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "Backslash",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-split-down",
|
||||
section: "global",
|
||||
label: "Split pane down",
|
||||
keys: ["mod", "shift", "\\"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-focus-left-cmd-shift-left",
|
||||
action: "workspace.pane.focus.left",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowLeft",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-focus-left",
|
||||
section: "global",
|
||||
label: "Focus pane left",
|
||||
keys: ["mod", "shift", "Left"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-focus-right-cmd-shift-right",
|
||||
action: "workspace.pane.focus.right",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowRight",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-focus-right",
|
||||
section: "global",
|
||||
label: "Focus pane right",
|
||||
keys: ["mod", "shift", "Right"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-focus-up-cmd-shift-up",
|
||||
action: "workspace.pane.focus.up",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowUp",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-focus-up",
|
||||
section: "global",
|
||||
label: "Focus pane up",
|
||||
keys: ["mod", "shift", "Up"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-focus-down-cmd-shift-down",
|
||||
action: "workspace.pane.focus.down",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowDown",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-focus-down",
|
||||
section: "global",
|
||||
label: "Focus pane down",
|
||||
keys: ["mod", "shift", "Down"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-move-tab-left-cmd-shift-alt-left",
|
||||
action: "workspace.pane.move-tab.left",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowLeft",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-left",
|
||||
section: "global",
|
||||
label: "Move tab left",
|
||||
keys: ["mod", "shift", "alt", "Left"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-move-tab-right-cmd-shift-alt-right",
|
||||
action: "workspace.pane.move-tab.right",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowRight",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-right",
|
||||
section: "global",
|
||||
label: "Move tab right",
|
||||
keys: ["mod", "shift", "alt", "Right"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-move-tab-up-cmd-shift-alt-up",
|
||||
action: "workspace.pane.move-tab.up",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowUp",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-up",
|
||||
section: "global",
|
||||
label: "Move tab up",
|
||||
keys: ["mod", "shift", "alt", "Up"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-move-tab-down-cmd-shift-alt-down",
|
||||
action: "workspace.pane.move-tab.down",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowDown",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-down",
|
||||
section: "global",
|
||||
label: "Move tab down",
|
||||
keys: ["mod", "shift", "alt", "Down"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-close-cmd-shift-w",
|
||||
action: "workspace.pane.close",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-close",
|
||||
section: "global",
|
||||
label: "Close pane",
|
||||
keys: ["mod", "shift", "W"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "command-center-toggle",
|
||||
action: "command-center.toggle",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (provider === "claude") {
|
||||
return "Claude";
|
||||
}
|
||||
if (provider === "codex") {
|
||||
return "Codex";
|
||||
}
|
||||
if (!provider) {
|
||||
return "Agent";
|
||||
}
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
|
||||
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function useAgentPanelDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string }
|
||||
): PanelDescriptor {
|
||||
const agent = useSessionStore(
|
||||
(state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null
|
||||
);
|
||||
const provider = agent?.provider ?? "codex";
|
||||
const label = resolveWorkspaceAgentTabLabel(agent?.title);
|
||||
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon,
|
||||
statusBucket: agent
|
||||
? deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
})
|
||||
: 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={({ filePath }) => {
|
||||
openFileInWorkspace(filePath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
||||
kind: "agent",
|
||||
component: AgentPanel,
|
||||
useDescriptor: useAgentPanelDescriptor,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { SquarePen } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
|
||||
function useDraftPanelDescriptor() {
|
||||
return {
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
titleState: "ready" as const,
|
||||
icon: SquarePen,
|
||||
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={({ filePath }) => {
|
||||
openFileInWorkspace(filePath);
|
||||
}}
|
||||
onCreated={(agentSnapshot) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
useSessionStore.getState().setAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentSnapshot.id, normalized);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const draftPanelRegistration: PanelRegistration<"draft"> = {
|
||||
kind: "draft",
|
||||
component: DraftPanel,
|
||||
useDescriptor: useDraftPanelDescriptor,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FileText } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { FilePane } from "@/components/file-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
|
||||
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
|
||||
return {
|
||||
label: fileName,
|
||||
subtitle: target.path,
|
||||
titleState: "ready" as const,
|
||||
icon: FileText,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const filePanelRegistration: PanelRegistration<"file"> = {
|
||||
kind: "file",
|
||||
component: FilePanel,
|
||||
useDescriptor: useFilePanelDescriptor,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export interface PaneContextValue {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
openTab(target: WorkspaceTabTarget): void;
|
||||
closeCurrentTab(): void;
|
||||
retargetCurrentTab(target: WorkspaceTabTarget): void;
|
||||
openFileInWorkspace(filePath: string): void;
|
||||
}
|
||||
|
||||
const PaneContext = createContext<PaneContextValue | null>(null);
|
||||
|
||||
export function PaneProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: PaneContextValue;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PaneContext.Provider value={value}>{children}</PaneContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePaneContext(): PaneContextValue {
|
||||
const value = useContext(PaneContext);
|
||||
invariant(value, "PaneContext is required");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
export interface PanelIconProps {
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface PanelDescriptor {
|
||||
label: string;
|
||||
subtitle: string;
|
||||
titleState: "ready" | "loading";
|
||||
icon: ComponentType<PanelIconProps>;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
}
|
||||
|
||||
export interface PanelDescriptorContext {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface PanelRegistration<
|
||||
K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"],
|
||||
> {
|
||||
kind: K;
|
||||
component: ComponentType;
|
||||
useDescriptor(
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
context: PanelDescriptorContext
|
||||
): PanelDescriptor;
|
||||
confirmClose?(
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
context: PanelDescriptorContext
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
|
||||
|
||||
export function registerPanel<K extends WorkspaceTabTarget["kind"]>(
|
||||
registration: PanelRegistration<K>
|
||||
): void {
|
||||
panelRegistry.set(registration.kind, registration as unknown as PanelRegistration);
|
||||
}
|
||||
|
||||
export function getPanelRegistration(
|
||||
kind: WorkspaceTabTarget["kind"]
|
||||
): PanelRegistration | undefined {
|
||||
return panelRegistry.get(kind);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||
|
||||
let panelsRegistered = false;
|
||||
|
||||
export function ensurePanelsRegistered(): void {
|
||||
if (panelsRegistered) {
|
||||
return;
|
||||
}
|
||||
registerPanel(draftPanelRegistration);
|
||||
registerPanel(agentPanelRegistration);
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
panelsRegistered = true;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Terminal } from "lucide-react-native";
|
||||
import { View } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { TerminalPane } from "@/components/terminal-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function useTerminalPanelDescriptor(
|
||||
target: { kind: "terminal"; terminalId: string },
|
||||
context: { serverId: string; workspaceId: string }
|
||||
): PanelDescriptor {
|
||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: ["terminals", context.serverId, context.workspaceId] as const,
|
||||
enabled: Boolean(client && context.workspaceId),
|
||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||
if (!client) {
|
||||
return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" };
|
||||
}
|
||||
return client.listTerminals(context.workspaceId);
|
||||
},
|
||||
staleTime: 5_000,
|
||||
});
|
||||
const terminal =
|
||||
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
||||
|
||||
return {
|
||||
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
|
||||
subtitle: "Terminal",
|
||||
titleState: "ready",
|
||||
icon: Terminal,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function TerminalPanel() {
|
||||
const isFocused = useIsFocused();
|
||||
const { serverId, workspaceId, target, openTab } = usePaneContext();
|
||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||
|
||||
if (!isFocused) {
|
||||
return <View style={{ flex: 1 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceId}
|
||||
selectedTerminalId={target.terminalId}
|
||||
onSelectedTerminalIdChange={(terminalId) => {
|
||||
if (!terminalId) {
|
||||
return;
|
||||
}
|
||||
openTab({ kind: "terminal", terminalId });
|
||||
}}
|
||||
hideHeader
|
||||
manageTerminalDirectorySubscription={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const terminalPanelRegistration: PanelRegistration<"terminal"> = {
|
||||
kind: "terminal",
|
||||
component: TerminalPanel,
|
||||
useDescriptor: useTerminalPanelDescriptor,
|
||||
};
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "@/utils/managed-tauri-daemon-transport";
|
||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
@@ -433,7 +432,6 @@ function probeIntervalForConnection(
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
@@ -454,17 +452,11 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
if (connection.type === "directTcp") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: connection.relayEndpoint,
|
||||
serverId: host.serverId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
useAgentScreenStateMachine,
|
||||
type AgentScreenMissingState,
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import {
|
||||
@@ -164,9 +164,9 @@ function AgentScreenContent({
|
||||
onOpenWorkspaceFile,
|
||||
}: AgentScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const insets = useSafeAreaInsets();
|
||||
const queryClient = useQueryClient();
|
||||
const panelToast = useToastHost();
|
||||
const resolvedAgentId = agentId;
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
|
||||
@@ -471,12 +471,12 @@ function AgentScreenContent({
|
||||
}
|
||||
if (!reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = true;
|
||||
toast.show("Reconnecting...", {
|
||||
panelToast.api.show("Reconnecting...", {
|
||||
durationMs: 2200,
|
||||
testID: "agent-reconnecting-toast",
|
||||
});
|
||||
}
|
||||
}, [connectionStatus, toast]);
|
||||
}, [connectionStatus, panelToast.api]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -833,14 +833,15 @@ function AgentScreenContent({
|
||||
useDelayedHistoryRefreshToast({
|
||||
isCatchingUp: isHistoryRefreshCatchingUp,
|
||||
indicatorColor: theme.colors.primary,
|
||||
showToast: panelToast.api.show,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEmitSyncErrorToast) {
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [shouldEmitSyncErrorToast, toast]);
|
||||
panelToast.api.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [panelToast.api, shouldEmitSyncErrorToast]);
|
||||
|
||||
if (viewState.tag === "not_found") {
|
||||
return (
|
||||
@@ -935,6 +936,12 @@ function AgentScreenContent({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ToastViewport
|
||||
toast={panelToast.toast}
|
||||
onDismiss={panelToast.dismiss}
|
||||
placement="panel"
|
||||
/>
|
||||
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
|
||||
@@ -213,10 +213,13 @@ function DraftAgentScreenContent({
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
setProviderAndModelFromUser,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
initialServerId: resolvedServerId ?? null,
|
||||
@@ -1199,6 +1202,9 @@ function DraftAgentScreenContent({
|
||||
selectedModel,
|
||||
onSelectModel: setModelFromUser,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
|
||||
@@ -5,18 +5,18 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FolderOpen } from "lucide-react-native";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
|
||||
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) {
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
|
||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
|
||||
@@ -42,7 +42,9 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
|
||||
styles.openButton,
|
||||
hovered && styles.openButtonHovered,
|
||||
]}
|
||||
onPress={() => setProjectPickerOpen(true)}
|
||||
onPress={() => {
|
||||
void openProjectPicker();
|
||||
}}
|
||||
testID="open-project-submit"
|
||||
>
|
||||
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
|
||||
|
||||
@@ -47,6 +47,7 @@ import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -513,6 +514,7 @@ export default function SettingsScreen() {
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const isDesktop = Platform.OS === "web";
|
||||
const isLocalDaemon = useIsLocalDaemon(routeServerId);
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
const editingServerId = editingDaemon?.serverId ?? null;
|
||||
@@ -906,7 +908,12 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
|
||||
{isDesktop ? <DesktopPermissionsSection /> : null}
|
||||
{isDesktop ? <LocalDaemonSection appVersion={appVersion} /> : null}
|
||||
{isDesktop ? (
|
||||
<LocalDaemonSection
|
||||
appVersion={appVersion}
|
||||
showLifecycleControls={isLocalDaemon}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Diagnostics</Text>
|
||||
|
||||
@@ -10,11 +10,7 @@ function makeAgentTab(id: string): WorkspaceTabDescriptor {
|
||||
key: `agent_${id}`,
|
||||
tabId: `agent_${id}`,
|
||||
kind: "agent",
|
||||
agentId: id,
|
||||
provider: "codex",
|
||||
label: `Agent ${id}`,
|
||||
subtitle: "",
|
||||
titleState: "ready",
|
||||
target: { kind: "agent", agentId: id },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,9 +19,7 @@ function makeTerminalTab(id: string): WorkspaceTabDescriptor {
|
||||
key: `terminal_${id}`,
|
||||
tabId: `terminal_${id}`,
|
||||
kind: "terminal",
|
||||
terminalId: id,
|
||||
label: `Terminal ${id}`,
|
||||
subtitle: "",
|
||||
target: { kind: "terminal", terminalId: id },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,9 +28,7 @@ function makeFileTab(path: string): WorkspaceTabDescriptor {
|
||||
key: `file_${path}`,
|
||||
tabId: `file_${path}`,
|
||||
kind: "file",
|
||||
filePath: path,
|
||||
label: path.split("/").pop() ?? path,
|
||||
subtitle: path,
|
||||
target: { kind: "file", path },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
|
||||
};
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (tab.kind === "agent") {
|
||||
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId });
|
||||
if (tab.target.kind === "agent") {
|
||||
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId });
|
||||
continue;
|
||||
}
|
||||
if (tab.kind === "terminal") {
|
||||
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId });
|
||||
if (tab.target.kind === "terminal") {
|
||||
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId });
|
||||
continue;
|
||||
}
|
||||
groups.otherTabs.push({ tabId: tab.tabId });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { Plus, X } from "lucide-react-native";
|
||||
import { ActivityIndicator, Platform, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import {
|
||||
@@ -12,31 +12,42 @@ import {
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||
import {
|
||||
deriveWorkspaceTabPresentation,
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import {
|
||||
buildWorkspaceDesktopTabActions,
|
||||
type WorkspaceDesktopTabActions,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
type NewTabOptionId = "__new_tab_agent__";
|
||||
type NewTabSelection = {
|
||||
optionId: NewTabOptionId;
|
||||
paneId?: string;
|
||||
};
|
||||
|
||||
export interface WorkspaceDesktopTabRowItem {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
isActive: boolean;
|
||||
isCloseHovered: boolean;
|
||||
isClosingTab: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceDesktopTabsRowProps = {
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
agentsById: Map<string, Agent>;
|
||||
paneId?: string;
|
||||
isFocused?: boolean;
|
||||
tabs: WorkspaceDesktopTabRowItem[];
|
||||
normalizedServerId: string;
|
||||
hoveredCloseTabKey: string | null;
|
||||
normalizedWorkspaceId: string;
|
||||
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;
|
||||
@@ -44,22 +55,234 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
|
||||
onSelectNewTabOption: (selection: NewTabSelection) => void;
|
||||
newTabAgentOptionId: NewTabOptionId;
|
||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||
onSplitRight: () => void;
|
||||
onSplitDown: () => void;
|
||||
externalDndContext?: boolean;
|
||||
activeDragTabId?: string | null;
|
||||
tabDropPreviewIndex?: number | null;
|
||||
};
|
||||
|
||||
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
return "Terminal";
|
||||
}
|
||||
if (tab.target.kind === "file") {
|
||||
return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path;
|
||||
}
|
||||
return "Agent";
|
||||
}
|
||||
|
||||
function TabChip({
|
||||
tab,
|
||||
isActive,
|
||||
isDragging,
|
||||
isFocused,
|
||||
resolvedTabWidth,
|
||||
showLabel,
|
||||
showCloseButton,
|
||||
isCloseHovered,
|
||||
isClosingTab,
|
||||
presentation,
|
||||
tooltipLabel,
|
||||
resolvedTab,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
isActive: boolean;
|
||||
isDragging: boolean;
|
||||
isFocused: boolean;
|
||||
resolvedTabWidth: number;
|
||||
showLabel: boolean;
|
||||
showCloseButton: boolean;
|
||||
isCloseHovered: boolean;
|
||||
isClosingTab: boolean;
|
||||
presentation: WorkspaceTabPresentation;
|
||||
tooltipLabel: string;
|
||||
resolvedTab: WorkspaceDesktopTabActions;
|
||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
dragHandleProps: any;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isHighlighted = isActive || hovered || isCloseHovered;
|
||||
const closeButtonDragBlockers =
|
||||
Platform.OS === "web"
|
||||
? ({
|
||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
onMouseDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
<ContextMenuTrigger
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
testID={`workspace-tab-${tab.key}`}
|
||||
triggerRef={dragHandleProps?.setActivatorNodeRef as any}
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
Platform.OS === "web" &&
|
||||
isDragging &&
|
||||
({ cursor: "grabbing" } as const),
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
width: resolvedTabWidth,
|
||||
maxWidth: resolvedTabWidth,
|
||||
},
|
||||
]}
|
||||
onHoverIn={() => {
|
||||
setHovered(true);
|
||||
setHoveredTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHovered(false);
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPressIn={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
onPress={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
accessibilityLabel={tooltipLabel}
|
||||
>
|
||||
{isActive && (
|
||||
<View
|
||||
style={[
|
||||
styles.tabFocusIndicator,
|
||||
!isFocused && styles.tabFocusIndicatorUnfocused,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.tabHandle}>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
|
||||
</View>
|
||||
{showLabel ? (
|
||||
presentation.titleState === "loading" ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabLabelSkeleton,
|
||||
showCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
isHighlighted && styles.tabLabelActive,
|
||||
showCloseButton && styles.tabLabelWithCloseButton,
|
||||
]}
|
||||
selectable={false}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{presentation.label}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{showCloseButton ? (
|
||||
<Pressable
|
||||
{...(closeButtonDragBlockers as any)}
|
||||
testID={closeButtonTestId}
|
||||
disabled={isClosingTab}
|
||||
onPressIn={(event) => {
|
||||
event.stopPropagation?.();
|
||||
}}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
setHoveredCloseTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation?.();
|
||||
void onCloseTab(tab.tabId);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tabCloseButton,
|
||||
styles.tabCloseButtonShown,
|
||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
||||
]}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
isClosingTab ? (
|
||||
<ActivityIndicator
|
||||
size={12}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
<X
|
||||
size={12}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<ContextMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
{entry.label}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceDesktopTabsRow({
|
||||
paneId,
|
||||
isFocused = false,
|
||||
tabs,
|
||||
activeTabKey,
|
||||
agentsById,
|
||||
normalizedServerId,
|
||||
hoveredCloseTabKey,
|
||||
normalizedWorkspaceId,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalPending,
|
||||
killTerminalId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
@@ -70,6 +293,12 @@ export function WorkspaceDesktopTabsRow({
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
onReorderTabs,
|
||||
onNewTerminalTab,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
externalDndContext = false,
|
||||
activeDragTabId = null,
|
||||
tabDropPreviewIndex = null,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||
@@ -89,8 +318,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
() => ({
|
||||
rowHorizontalInset: 0,
|
||||
actionsReservedWidth: Math.max(0, tabsActionsWidth),
|
||||
rowPaddingHorizontal: theme.spacing[2],
|
||||
tabGap: theme.spacing[1],
|
||||
rowPaddingHorizontal: 0,
|
||||
tabGap: 0,
|
||||
maxTabWidth: 200,
|
||||
tabIconWidth: 14,
|
||||
tabHorizontalPadding: theme.spacing[3],
|
||||
@@ -103,12 +332,10 @@ export function WorkspaceDesktopTabsRow({
|
||||
const tabLabelLengths = useMemo(
|
||||
() =>
|
||||
tabs.map((tab) => {
|
||||
if (tab.kind === "agent" && tab.titleState === "loading") {
|
||||
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
|
||||
}
|
||||
return tab.label.length;
|
||||
const label = getFallbackTabLabel(tab.tab);
|
||||
return label.length;
|
||||
}),
|
||||
[layoutMetrics.estimatedCharWidth, tabs]
|
||||
[tabs]
|
||||
);
|
||||
|
||||
const { layout } = useWorkspaceTabLayout({
|
||||
@@ -136,192 +363,73 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<SortableInlineList
|
||||
data={tabs}
|
||||
keyExtractor={(tab) => tab.key}
|
||||
keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
|
||||
useDragHandle
|
||||
disabled={tabs.length < 2}
|
||||
onDragEnd={onReorderTabs}
|
||||
renderItem={({ item: tab, index, dragHandleProps }) => {
|
||||
const isActive = tab.key === activeTabKey;
|
||||
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
|
||||
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
||||
const isClosingAgent =
|
||||
tab.kind === "agent" &&
|
||||
isArchivingAgent({
|
||||
serverId: normalizedServerId,
|
||||
agentId: tab.agentId,
|
||||
});
|
||||
const isClosingTerminal =
|
||||
tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId;
|
||||
const isClosingTab = isClosingAgent || isClosingTerminal;
|
||||
disabled={!externalDndContext && tabs.length < 2}
|
||||
onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
|
||||
externalDndContext={externalDndContext}
|
||||
activeId={activeDragTabId}
|
||||
getItemData={
|
||||
paneId
|
||||
? (tab) => ({
|
||||
kind: "workspace-tab",
|
||||
paneId,
|
||||
tabId: tab.tab.tabId,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
renderItem={({ item, index, dragHandleProps, isActive }) => {
|
||||
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
|
||||
const layoutItem = layout.items[index] ?? null;
|
||||
const resolvedTabWidth = layoutItem?.width ?? 150;
|
||||
const showLabel = layoutItem?.showLabel ?? true;
|
||||
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
||||
const tooltipLabel =
|
||||
tab.kind === "agent" && tab.titleState === "loading"
|
||||
? "Loading agent title"
|
||||
: presentation.label;
|
||||
|
||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab,
|
||||
index,
|
||||
tabCount: tabs.length,
|
||||
menuTestIDBase: contextMenuTestId,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsToLeft,
|
||||
onCloseTabsAfter: onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
const showDropIndicatorBefore =
|
||||
activeDragTabId !== null && tabDropPreviewIndex === index;
|
||||
const showDropIndicatorAfter =
|
||||
activeDragTabId !== null &&
|
||||
tabDropPreviewIndex === tabs.length &&
|
||||
index === tabs.length - 1;
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
<ContextMenuTrigger
|
||||
testID={`workspace-tab-${tab.key}`}
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
width: resolvedTabWidth,
|
||||
maxWidth: resolvedTabWidth,
|
||||
},
|
||||
isActive && styles.tabActive,
|
||||
!isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||
]}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPressIn={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
onPress={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
accessibilityLabel={tooltipLabel}
|
||||
>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
style={styles.tabHandle}
|
||||
>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isActive} />
|
||||
</View>
|
||||
{showLabel ? (
|
||||
presentation.titleState === "loading" ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabLabelSkeleton,
|
||||
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
isActive && styles.tabLabelActive,
|
||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||
]}
|
||||
selectable={false}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{shouldShowCloseButton ? (
|
||||
<Pressable
|
||||
testID={
|
||||
tab.kind === "agent"
|
||||
? `workspace-agent-close-${tab.agentId}`
|
||||
: tab.kind === "terminal"
|
||||
? `workspace-terminal-close-${tab.terminalId}`
|
||||
: tab.kind === "draft"
|
||||
? `workspace-draft-close-${tab.draftId}`
|
||||
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
|
||||
}
|
||||
disabled={isClosingTab}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
setHoveredCloseTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation?.();
|
||||
void onCloseTab(tab.tabId);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tabCloseButton,
|
||||
styles.tabCloseButtonShown,
|
||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
||||
]}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
isClosingTab ? (
|
||||
<ActivityIndicator
|
||||
size={12}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
<X
|
||||
size={12}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<ContextMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
{entry.label}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<ResolvedDesktopTabChip
|
||||
key={`${item.tab.key}:${item.tab.kind}`}
|
||||
item={item}
|
||||
isFocused={isFocused}
|
||||
isDragging={isActive}
|
||||
index={index}
|
||||
tabCount={tabs.length}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={shouldShowCloseButton}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
dragHandleProps={dragHandleProps}
|
||||
showDropIndicatorBefore={showDropIndicatorBefore}
|
||||
showDropIndicatorAfter={showDropIndicatorAfter}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-agent-tab"
|
||||
onPress={() => onSelectNewTabOption(newTabAgentOptionId)}
|
||||
onPress={() =>
|
||||
onSelectNewTabOption({
|
||||
optionId: newTabAgentOptionId,
|
||||
paneId,
|
||||
})
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New agent tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
@@ -329,7 +437,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
@@ -338,52 +446,219 @@ export function WorkspaceDesktopTabsRow({
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={() => onNewTerminalTab({ paneId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New terminal tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onSplitRight}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Split pane right"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onSplitDown}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Split pane down"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedDesktopTabChip({
|
||||
item,
|
||||
isFocused,
|
||||
isDragging,
|
||||
index,
|
||||
tabCount,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
resolvedTabWidth,
|
||||
showLabel,
|
||||
showCloseButton,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
dragHandleProps,
|
||||
showDropIndicatorBefore,
|
||||
showDropIndicatorAfter,
|
||||
}: {
|
||||
item: WorkspaceDesktopTabRowItem;
|
||||
isFocused: boolean;
|
||||
isDragging: boolean;
|
||||
index: number;
|
||||
tabCount: number;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
resolvedTabWidth: number;
|
||||
showLabel: boolean;
|
||||
showCloseButton: boolean;
|
||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
dragHandleProps: any;
|
||||
showDropIndicatorBefore: boolean;
|
||||
showDropIndicatorAfter: boolean;
|
||||
}) {
|
||||
const resolvedTab = useMemo(
|
||||
() =>
|
||||
buildWorkspaceDesktopTabActions({
|
||||
tab: item.tab,
|
||||
index,
|
||||
tabCount,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
}),
|
||||
[
|
||||
index,
|
||||
item.tab,
|
||||
onCloseOtherTabs,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCopyAgentId,
|
||||
onCopyResumeCommand,
|
||||
tabCount,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={item.tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
const tooltipLabel =
|
||||
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
|
||||
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
{showDropIndicatorBefore ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
|
||||
) : null}
|
||||
<TabChip
|
||||
tab={item.tab}
|
||||
isActive={item.isActive}
|
||||
isDragging={isDragging}
|
||||
isFocused={isFocused}
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={showCloseButton}
|
||||
isCloseHovered={item.isCloseHovered}
|
||||
isClosingTab={item.isClosingTab}
|
||||
presentation={presentation}
|
||||
tooltipLabel={tooltipLabel}
|
||||
resolvedTab={resolvedTab}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
{showDropIndicatorAfter ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorAfter]} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
tabsContainer: {
|
||||
minWidth: 0,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
overflow: "visible",
|
||||
},
|
||||
tabsScroll: {
|
||||
minWidth: 0,
|
||||
},
|
||||
tabsScrollFitContent: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flex: 1,
|
||||
},
|
||||
tabsScrollOverflow: {
|
||||
flex: 1,
|
||||
},
|
||||
tabsContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
alignItems: "stretch",
|
||||
},
|
||||
tabsActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
tab: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
tabSlot: {
|
||||
position: "relative",
|
||||
overflow: "visible",
|
||||
},
|
||||
tabHandle: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -395,11 +670,32 @@ const styles = StyleSheet.create((theme) => ({
|
||||
tabIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
tabFocusIndicator: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
tabHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
tabFocusIndicatorUnfocused: {
|
||||
backgroundColor: theme.colors.borderAccent,
|
||||
},
|
||||
tabDropIndicator: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
bottom: theme.spacing[2],
|
||||
width: 5,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
tabDropIndicatorBefore: {
|
||||
left: -3,
|
||||
},
|
||||
tabDropIndicatorAfter: {
|
||||
right: -3,
|
||||
},
|
||||
tabLabel: {
|
||||
flexShrink: 1,
|
||||
@@ -443,8 +739,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
newTabActionButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -57,8 +57,11 @@ export function WorkspaceDraftAgentTab({
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
setProviderAndModelFromUser,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
initialServerId: serverId,
|
||||
@@ -246,6 +249,9 @@ export function WorkspaceDraftAgentTab({
|
||||
selectedModel,
|
||||
onSelectModel: setModelFromUser,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ComponentType } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { PaneProvider, type PaneContextValue } from "@/panels/pane-context";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export interface WorkspacePaneContentModel {
|
||||
key: string;
|
||||
Component: ComponentType;
|
||||
paneContextValue: PaneContextValue;
|
||||
}
|
||||
|
||||
export interface BuildWorkspacePaneContentModelInput {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onCloseCurrentTab: () => void;
|
||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onOpenWorkspaceFile: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function buildWorkspacePaneContentModel({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
onOpenTab,
|
||||
onCloseCurrentTab,
|
||||
onRetargetCurrentTab,
|
||||
onOpenWorkspaceFile,
|
||||
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
return {
|
||||
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
|
||||
Component: registration.component,
|
||||
paneContextValue: {
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: tab.tabId,
|
||||
target: tab.target,
|
||||
openTab: onOpenTab,
|
||||
closeCurrentTab: onCloseCurrentTab,
|
||||
retargetCurrentTab: onRetargetCurrentTab,
|
||||
openFileInWorkspace: onOpenWorkspaceFile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspacePaneContentProps {
|
||||
content: WorkspacePaneContentModel;
|
||||
}
|
||||
|
||||
export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) {
|
||||
const { Component, key, paneContextValue } = content;
|
||||
|
||||
return (
|
||||
<PaneProvider value={paneContextValue}>
|
||||
<Component key={key} />
|
||||
</PaneProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveWorkspacePaneState, getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
|
||||
function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab {
|
||||
return {
|
||||
tabId,
|
||||
target,
|
||||
createdAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace-pane-state", () => {
|
||||
it("selects the focused pane and keeps its tab order", () => {
|
||||
const tabs: WorkspaceTab[] = [
|
||||
createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" }),
|
||||
createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" }),
|
||||
createTab("terminal_term-1", { kind: "terminal", terminalId: "term-1" }),
|
||||
];
|
||||
const layout: WorkspaceLayout = {
|
||||
root: {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "left",
|
||||
tabIds: ["file_/repo/README.md", "agent_agent-a"],
|
||||
focusedTabId: "agent_agent-a",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "right",
|
||||
tabIds: ["terminal_term-1"],
|
||||
focusedTabId: "terminal_term-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "left",
|
||||
};
|
||||
|
||||
const state = deriveWorkspacePaneState({ layout, tabs });
|
||||
|
||||
expect(state.pane?.id).toBe("left");
|
||||
expect(state.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
"file_/repo/README.md",
|
||||
"agent_agent-a",
|
||||
]);
|
||||
expect(state.activeTabId).toBe("agent_agent-a");
|
||||
});
|
||||
|
||||
it("falls back to the first ordered pane tab when focusedTabId is empty", () => {
|
||||
const pane = {
|
||||
id: "main",
|
||||
tabIds: ["draft_1", "draft_2"],
|
||||
focusedTabId: " ",
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
createTab("draft_2", { kind: "draft", draftId: "draft_2" }),
|
||||
createTab("draft_1", { kind: "draft", draftId: "draft_1" }),
|
||||
];
|
||||
|
||||
const state = deriveWorkspacePaneState({ pane, tabs });
|
||||
|
||||
expect(state.activeTabId).toBe("draft_1");
|
||||
expect(getWorkspacePaneDescriptors({ pane, tabs }).map((tab) => tab.tabId)).toEqual([
|
||||
"draft_1",
|
||||
"draft_2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers a matching target over stale focused tab state", () => {
|
||||
const pane = {
|
||||
id: "main",
|
||||
tabIds: ["agent_agent-a", "file_/repo/README.md"],
|
||||
focusedTabId: "agent_agent-a",
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" }),
|
||||
createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" }),
|
||||
];
|
||||
|
||||
const state = deriveWorkspacePaneState({
|
||||
pane,
|
||||
tabs,
|
||||
preferredTarget: { kind: "file", path: "\\repo\\README.md" },
|
||||
});
|
||||
|
||||
expect(state.activeTabId).toBe("file_/repo/README.md");
|
||||
expect(state.activeTab?.descriptor.target).toEqual({
|
||||
kind: "file",
|
||||
path: "/repo/README.md",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
findPaneById,
|
||||
type SplitPane,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
|
||||
export interface WorkspaceDerivedTab {
|
||||
descriptor: WorkspaceTabDescriptor;
|
||||
}
|
||||
|
||||
export interface WorkspacePaneState {
|
||||
pane: SplitPane | null;
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
focusedTabId: string | null;
|
||||
activeTabId: string | null;
|
||||
activeTab: WorkspaceDerivedTab | null;
|
||||
}
|
||||
|
||||
interface NormalizeWorkspacePaneTabsResult {
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
openTabIds: Set<string>;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
|
||||
if (!tab || typeof tab !== "object") {
|
||||
return null;
|
||||
}
|
||||
const tabId = trimNonEmpty(tab.tabId);
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
const target = normalizeWorkspaceTabTarget(tab.target);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target,
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function orderPaneTabs(input: { pane: SplitPane | null; tabs: WorkspaceTab[] }): WorkspaceTab[] {
|
||||
if (!input.pane) {
|
||||
return input.tabs;
|
||||
}
|
||||
|
||||
const tabsById = new Map<string, WorkspaceTab>();
|
||||
for (const tab of input.tabs) {
|
||||
tabsById.set(tab.tabId, tab);
|
||||
}
|
||||
|
||||
const orderedTabs: WorkspaceTab[] = [];
|
||||
for (const tabId of input.pane.tabIds) {
|
||||
const tab = tabsById.get(tabId);
|
||||
if (tab) {
|
||||
orderedTabs.push(tab);
|
||||
}
|
||||
}
|
||||
return orderedTabs;
|
||||
}
|
||||
|
||||
function normalizeWorkspacePaneTabs(tabs: WorkspaceTab[]): NormalizeWorkspacePaneTabsResult {
|
||||
const nextTabs: WorkspaceDerivedTab[] = [];
|
||||
const openTabIds = new Set<string>();
|
||||
|
||||
for (const tab of tabs) {
|
||||
const normalizedTab = normalizeWorkspaceTab(tab);
|
||||
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
openTabIds.add(normalizedTab.tabId);
|
||||
nextTabs.push({
|
||||
descriptor: {
|
||||
key: normalizedTab.tabId,
|
||||
tabId: normalizedTab.tabId,
|
||||
kind: normalizedTab.target.kind,
|
||||
target: normalizedTab.target,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: nextTabs,
|
||||
openTabIds,
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveTabId(input: {
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
openTabIds: Set<string>;
|
||||
focusedTabId?: string | null;
|
||||
preferredTarget?: WorkspaceTabTarget | null;
|
||||
}): string | null {
|
||||
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||
const preferredTarget = normalizeWorkspaceTabTarget(input.preferredTarget ?? null);
|
||||
const preferredTabId = (() => {
|
||||
if (!preferredTarget) {
|
||||
return null;
|
||||
}
|
||||
const matchingTab =
|
||||
input.tabs.find((tab) => workspaceTabTargetsEqual(tab.descriptor.target, preferredTarget)) ??
|
||||
null;
|
||||
return matchingTab?.descriptor.tabId ?? buildDeterministicWorkspaceTabId(preferredTarget);
|
||||
})();
|
||||
|
||||
if (preferredTabId && input.openTabIds.has(preferredTabId)) {
|
||||
return preferredTabId;
|
||||
}
|
||||
if (focusedTabId && input.openTabIds.has(focusedTabId)) {
|
||||
return focusedTabId;
|
||||
}
|
||||
return input.tabs[0]?.descriptor.tabId ?? null;
|
||||
}
|
||||
|
||||
function getPane(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
pane?: SplitPane | null;
|
||||
paneId?: string | null;
|
||||
}): SplitPane | null {
|
||||
if (input.pane) {
|
||||
return input.pane;
|
||||
}
|
||||
|
||||
const layout = input.layout;
|
||||
if (!layout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPaneId = trimNonEmpty(input.paneId) ?? layout.focusedPaneId;
|
||||
if (!resolvedPaneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findPaneById(layout.root, resolvedPaneId);
|
||||
}
|
||||
|
||||
export function deriveWorkspacePaneState(input: {
|
||||
layout?: WorkspaceLayout | null;
|
||||
pane?: SplitPane | null;
|
||||
paneId?: string | null;
|
||||
tabs: WorkspaceTab[];
|
||||
focusedTabId?: string | null;
|
||||
preferredTarget?: WorkspaceTabTarget | null;
|
||||
}): WorkspacePaneState {
|
||||
const pane = getPane({
|
||||
layout: input.layout ?? null,
|
||||
pane: input.pane ?? null,
|
||||
paneId: input.paneId,
|
||||
});
|
||||
const orderedTabs = orderPaneTabs({
|
||||
pane,
|
||||
tabs: input.tabs,
|
||||
});
|
||||
const normalizedTabs = normalizeWorkspacePaneTabs(orderedTabs);
|
||||
const focusedTabId = pane?.focusedTabId ?? trimNonEmpty(input.focusedTabId) ?? null;
|
||||
const activeTabId = getActiveTabId({
|
||||
tabs: normalizedTabs.tabs,
|
||||
openTabIds: normalizedTabs.openTabIds,
|
||||
focusedTabId,
|
||||
preferredTarget: input.preferredTarget,
|
||||
});
|
||||
|
||||
return {
|
||||
pane,
|
||||
tabs: normalizedTabs.tabs,
|
||||
focusedTabId,
|
||||
activeTabId,
|
||||
activeTab:
|
||||
activeTabId
|
||||
? normalizedTabs.tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspacePaneDescriptors(input: {
|
||||
layout?: WorkspaceLayout | null;
|
||||
pane?: SplitPane | null;
|
||||
paneId?: string | null;
|
||||
tabs: WorkspaceTab[];
|
||||
}): WorkspaceTabDescriptor[] {
|
||||
return deriveWorkspacePaneState(input).tabs.map((tab) => tab.descriptor);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ const metrics = {
|
||||
};
|
||||
|
||||
describe("computeWorkspaceTabLayout", () => {
|
||||
it("keeps fit-content tab widths when there is extra horizontal space", () => {
|
||||
it("caps equal-width tabs at the ideal width when there is extra horizontal space", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 1200,
|
||||
tabLabelLengths: [8, 10, 7],
|
||||
@@ -25,10 +25,10 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
expect(result.items.map((item) => item.width)).toEqual([126, 140, 119]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([200, 200, 200]);
|
||||
});
|
||||
|
||||
it("shrinks tab widths proportionally before hiding labels", () => {
|
||||
it("shrinks equal-width tabs proportionally to fit the pane", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 520,
|
||||
tabLabelLengths: [24, 12, 8],
|
||||
@@ -37,10 +37,27 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([151, 121, 103]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([125, 125, 125]);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the split width for evenly sized tabs when space is available", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 743,
|
||||
tabLabelLengths: [8, 8, 8, 8],
|
||||
metrics: {
|
||||
...metrics,
|
||||
actionsReservedWidth: 44,
|
||||
rowPaddingHorizontal: 0,
|
||||
tabGap: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([175, 175, 175, 175]);
|
||||
});
|
||||
|
||||
it("collapses to icon-only before allowing horizontal scroll fallback", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 388,
|
||||
|
||||
@@ -38,14 +38,6 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function sum(values: number[]): number {
|
||||
let total = 0;
|
||||
for (const value of values) {
|
||||
total += value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function computeWorkspaceTabLayout(
|
||||
input: WorkspaceTabLayoutInput
|
||||
): WorkspaceTabLayoutResult {
|
||||
@@ -67,33 +59,13 @@ export function computeWorkspaceTabLayout(
|
||||
const availableTabsWidth = Math.max(0, availableWidth - rowOverhead);
|
||||
const iconOnlyTabWidth =
|
||||
input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2 + input.metrics.closeButtonWidth;
|
||||
const labelSafetyPadding = 10;
|
||||
const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
|
||||
const desiredTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||
const labelLength = Math.max(rawLength, 1);
|
||||
const estimatedWidth = iconOnlyTabWidth + estimateLabelWidth(labelLength) + labelSafetyPadding;
|
||||
return clamp(estimatedWidth, iconOnlyTabWidth, input.metrics.maxTabWidth);
|
||||
});
|
||||
|
||||
const desiredTotalTabsWidth = sum(desiredTabWidths);
|
||||
const iconOnlyTotalTabsWidth = iconOnlyTabWidth * tabCount;
|
||||
const requiresHorizontalScrollFallback = availableTabsWidth < iconOnlyTotalTabsWidth;
|
||||
|
||||
let resolvedWidths: number[];
|
||||
if (desiredTotalTabsWidth <= availableTabsWidth) {
|
||||
// Fit-content baseline: do not stretch tabs when there is spare space.
|
||||
resolvedWidths = desiredTabWidths;
|
||||
} else if (availableTabsWidth <= iconOnlyTotalTabsWidth) {
|
||||
resolvedWidths = new Array(tabCount).fill(iconOnlyTabWidth);
|
||||
} else {
|
||||
const desiredExpandableWidth = desiredTotalTabsWidth - iconOnlyTotalTabsWidth;
|
||||
const availableExpandableWidth = availableTabsWidth - iconOnlyTotalTabsWidth;
|
||||
const proportionalRatio =
|
||||
desiredExpandableWidth > 0 ? availableExpandableWidth / desiredExpandableWidth : 0;
|
||||
resolvedWidths = desiredTabWidths.map(
|
||||
(desiredWidth) => iconOnlyTabWidth + (desiredWidth - iconOnlyTabWidth) * proportionalRatio
|
||||
);
|
||||
}
|
||||
const resolvedWidths = new Array(tabCount).fill(
|
||||
requiresHorizontalScrollFallback
|
||||
? iconOnlyTabWidth
|
||||
: clamp(availableTabsWidth / tabCount, iconOnlyTabWidth, input.metrics.maxTabWidth)
|
||||
);
|
||||
|
||||
const roundedWidths = resolvedWidths.map((width) =>
|
||||
Math.round(clamp(width, iconOnlyTabWidth, input.metrics.maxTabWidth))
|
||||
|
||||
@@ -7,11 +7,7 @@ function createAgentTab(): WorkspaceTabDescriptor {
|
||||
key: "agent_123",
|
||||
tabId: "agent_123",
|
||||
kind: "agent",
|
||||
agentId: "agent-123",
|
||||
provider: "codex",
|
||||
label: "Agent 123",
|
||||
subtitle: "/repo",
|
||||
titleState: "ready",
|
||||
target: { kind: "agent", agentId: "agent-123" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,9 +80,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
key: "draft_123",
|
||||
tabId: "draft_123",
|
||||
kind: "draft",
|
||||
draftId: "draft_123",
|
||||
label: "New agent",
|
||||
subtitle: "/repo",
|
||||
target: { kind: "draft", draftId: "draft_123" },
|
||||
},
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
|
||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||
|
||||
@@ -31,6 +32,24 @@ interface BuildWorkspaceTabMenuEntriesInput {
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface BuildWorkspaceDesktopTabActionsInput {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
index: number;
|
||||
tabCount: number;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface WorkspaceDesktopTabActions {
|
||||
contextMenuTestId: string;
|
||||
menuEntries: WorkspaceTabMenuEntry[];
|
||||
closeButtonTestId: string;
|
||||
}
|
||||
|
||||
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "Close tabs above" : "Close to the left";
|
||||
}
|
||||
@@ -47,6 +66,19 @@ function buildCloseAfterTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "close-below" : "close-right";
|
||||
}
|
||||
|
||||
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "agent") {
|
||||
return `workspace-agent-close-${tab.target.agentId}`;
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
return `workspace-terminal-close-${tab.target.terminalId}`;
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return `workspace-draft-close-${tab.target.draftId}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabMenuEntries(
|
||||
input: BuildWorkspaceTabMenuEntriesInput
|
||||
): WorkspaceTabMenuEntry[] {
|
||||
@@ -68,14 +100,15 @@ export function buildWorkspaceTabMenuEntries(
|
||||
const isOnlyTab = tabCount <= 1;
|
||||
const entries: WorkspaceTabMenuEntry[] = [];
|
||||
|
||||
if (tab.kind === "agent") {
|
||||
if (tab.target.kind === "agent") {
|
||||
const { agentId } = tab.target;
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "copy-resume-command",
|
||||
label: "Copy resume command",
|
||||
testID: `${menuTestIDBase}-copy-resume-command`,
|
||||
onSelect: () => {
|
||||
void onCopyResumeCommand(tab.agentId);
|
||||
void onCopyResumeCommand(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
@@ -84,7 +117,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
label: "Copy agent id",
|
||||
testID: `${menuTestIDBase}-copy-agent-id`,
|
||||
onSelect: () => {
|
||||
void onCopyAgentId(tab.agentId);
|
||||
void onCopyAgentId(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
@@ -135,3 +168,26 @@ export function buildWorkspaceTabMenuEntries(
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildWorkspaceDesktopTabActions(
|
||||
input: BuildWorkspaceDesktopTabActionsInput
|
||||
): WorkspaceDesktopTabActions {
|
||||
const contextMenuTestId = `workspace-tab-context-${input.tab.key}`;
|
||||
return {
|
||||
contextMenuTestId,
|
||||
menuEntries: buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab: input.tab,
|
||||
index: input.index,
|
||||
tabCount: input.tabCount,
|
||||
menuTestIDBase: contextMenuTestId,
|
||||
onCopyResumeCommand: input.onCopyResumeCommand,
|
||||
onCopyAgentId: input.onCopyAgentId,
|
||||
onCloseTab: input.onCloseTab,
|
||||
onCloseTabsBefore: input.onCloseTabsToLeft,
|
||||
onCloseTabsAfter: input.onCloseTabsToRight,
|
||||
onCloseOtherTabs: input.onCloseOtherTabs,
|
||||
}),
|
||||
closeButtonTestId: getCloseButtonTestId(input.tab),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,85 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string;
|
||||
provider?: Agent["provider"];
|
||||
title?: string | null;
|
||||
createdAt?: Date;
|
||||
lastActivityAt?: Date;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: Agent["attentionReason"];
|
||||
}): Agent {
|
||||
const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z");
|
||||
const lastActivityAt = input.lastActivityAt ?? createdAt;
|
||||
return {
|
||||
serverId: "srv",
|
||||
id: input.id,
|
||||
provider: input.provider ?? "codex",
|
||||
status: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: input.provider ?? "codex",
|
||||
sessionId: null,
|
||||
},
|
||||
title: input.title ?? null,
|
||||
cwd: "/repo/worktree",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
labels: {},
|
||||
requiresAttention: input.requiresAttention ?? false,
|
||||
attentionReason: input.attentionReason ?? null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveWorkspaceTabModel", () => {
|
||||
it("derives agent and terminal tabs from domain state, not UI membership", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [
|
||||
makeAgent({ id: "agent-a", title: "Build API" }),
|
||||
makeAgent({ id: "agent-b", title: "" }),
|
||||
],
|
||||
terminals: [{ id: "term-1", name: "shell" }],
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
|
||||
],
|
||||
tabOrder: [],
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
"agent_agent-a",
|
||||
"agent_agent-b",
|
||||
"terminal_term-1",
|
||||
]);
|
||||
const firstAgent = model.tabs[0]?.descriptor;
|
||||
const secondAgent = model.tabs[1]?.descriptor;
|
||||
expect(firstAgent?.kind === "agent" ? firstAgent.titleState : null).toBe("ready");
|
||||
expect(secondAgent?.kind === "agent" ? secondAgent.titleState : null).toBe("loading");
|
||||
expect(secondAgent?.label).toBe("");
|
||||
});
|
||||
|
||||
it("keeps draft and file tabs as explicit UI tabs", () => {
|
||||
it("keeps normalized tabs in stored order and preserves targets", () => {
|
||||
const uiTabs: WorkspaceTab[] = [
|
||||
{
|
||||
tabId: "draft_123",
|
||||
@@ -91,32 +15,37 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
target: { kind: "file", path: "/repo/worktree/README.md" },
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
tabId: "agent_agent-a",
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
...uiTabs,
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 3 },
|
||||
],
|
||||
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
|
||||
tabs: [uiTabs[0]!, uiTabs[2]!, uiTabs[1]!],
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]);
|
||||
expect(model.tabs[0]?.descriptor.label).toBe("New Agent");
|
||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
"draft_123",
|
||||
"agent_agent-a",
|
||||
"file_/repo/worktree/README.md",
|
||||
]);
|
||||
expect(model.tabs[0]?.descriptor.target).toEqual({ kind: "draft", draftId: "draft_123" });
|
||||
expect(model.tabs[1]?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" });
|
||||
expect(model.tabs[2]?.descriptor.target).toEqual({
|
||||
kind: "file",
|
||||
path: "/repo/worktree/README.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies stored order and appends newly-derived tabs deterministically", () => {
|
||||
it("applies stored order and appends unordered tabs deterministically", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||
terminals: [{ id: "term-1", name: "zsh" }],
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
],
|
||||
tabOrder: ["terminal_term-1", "agent_agent-b"],
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
@@ -128,13 +57,10 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
|
||||
it("uses focused tab when present, otherwise falls back to first tab", () => {
|
||||
const base: Parameters<typeof deriveWorkspaceTabModel>[0] = {
|
||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
],
|
||||
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
||||
};
|
||||
|
||||
expect(
|
||||
@@ -144,81 +70,50 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
}).activeTabId
|
||||
).toBe("agent_agent-b");
|
||||
|
||||
expect(
|
||||
deriveWorkspaceTabModel({
|
||||
...base,
|
||||
focusedTabId: "agent_agent-b",
|
||||
}).activeTabId
|
||||
).toBe("agent_agent-b");
|
||||
expect(deriveWorkspaceTabModel(base).activeTabId).toBe("agent_agent-a");
|
||||
});
|
||||
|
||||
it("prefers the route-selected target over stale focused tab state", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
],
|
||||
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
||||
focusedTabId: "agent_agent-a",
|
||||
preferredTarget: { kind: "agent", agentId: "agent-b" },
|
||||
});
|
||||
|
||||
expect(model.activeTabId).toBe("agent_agent-b");
|
||||
expect(model.activeTab?.target).toEqual({ kind: "agent", agentId: "agent-b" });
|
||||
expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-b" });
|
||||
});
|
||||
|
||||
it("re-resolves active content for a new workspace when prior focused tab is not available", () => {
|
||||
it("normalizes preferredTarget before overriding focused tab selection", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "agent_workspace-b-agent",
|
||||
target: { kind: "agent", agentId: "workspace-b-agent" },
|
||||
tabId: "file_/repo/worktree/README.md",
|
||||
target: { kind: "file", path: "/repo/worktree/README.md" },
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["agent_workspace-b-agent"],
|
||||
focusedTabId: "agent_workspace-a-agent",
|
||||
});
|
||||
|
||||
expect(model.activeTabId).toBe("agent_workspace-b-agent");
|
||||
expect(model.activeTab?.target).toEqual({
|
||||
kind: "agent",
|
||||
agentId: "workspace-b-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not materialize tabs absent from workspace tab membership", () => {
|
||||
const offending = makeAgent({
|
||||
id: "offender",
|
||||
title: "Needs permission",
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
});
|
||||
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [offending],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_123",
|
||||
target: { kind: "draft", draftId: "draft_123" },
|
||||
createdAt: 1,
|
||||
tabId: "agent_agent-a",
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_123"],
|
||||
focusedTabId: "agent_agent-a",
|
||||
preferredTarget: { kind: "file", path: "\\repo\\worktree\\README.md" },
|
||||
});
|
||||
|
||||
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(false);
|
||||
expect(model.activeTabId).toBe("file_/repo/worktree/README.md");
|
||||
expect(model.activeTab?.descriptor.target).toEqual({
|
||||
kind: "file",
|
||||
path: "/repo/worktree/README.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps retargeted agent tab id stable while upgrading descriptor data", () => {
|
||||
it("keeps retargeted tab ids stable while matching upgraded targets", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_abc",
|
||||
@@ -226,53 +121,34 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_abc"],
|
||||
});
|
||||
const initial = model.tabs[0]?.descriptor;
|
||||
expect(initial?.tabId).toBe("draft_abc");
|
||||
expect(initial?.kind).toBe("agent");
|
||||
if (initial?.kind === "agent") {
|
||||
expect(initial.titleState).toBe("loading");
|
||||
expect(initial.agentId).toBe("agent-1");
|
||||
}
|
||||
|
||||
const upgraded = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_abc",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_abc"],
|
||||
});
|
||||
const upgradedDescriptor = upgraded.tabs[0]?.descriptor;
|
||||
expect(upgradedDescriptor?.tabId).toBe("draft_abc");
|
||||
if (upgradedDescriptor?.kind === "agent") {
|
||||
expect(upgradedDescriptor.titleState).toBe("ready");
|
||||
expect(upgradedDescriptor.label).toBe("Ready title");
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers a retargeted tab when the route selects its upgraded agent target", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_abc",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_abc"],
|
||||
focusedTabId: "some_other_tab",
|
||||
preferredTarget: { kind: "agent", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(model.activeTabId).toBe("draft_abc");
|
||||
expect(model.activeTab?.descriptor.tabId).toBe("draft_abc");
|
||||
expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-1" });
|
||||
});
|
||||
|
||||
it("normalizes file paths and discards invalid tabs", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
tabs: [
|
||||
{
|
||||
tabId: "file_path",
|
||||
target: { kind: "file", path: "\\repo\\worktree\\README.md" },
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
tabId: "",
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(model.tabs).toHaveLength(1);
|
||||
expect(model.tabs[0]?.descriptor.target).toEqual({
|
||||
kind: "file",
|
||||
path: "/repo/worktree/README.md",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,283 +1,35 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
type WorkspaceDerivedTab,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
|
||||
type TerminalLike = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceDerivedTab = {
|
||||
descriptor: WorkspaceTabDescriptor;
|
||||
target: WorkspaceTabTarget;
|
||||
};
|
||||
|
||||
export type WorkspaceTabModel = {
|
||||
export interface WorkspaceTabModel {
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
activeTabId: string | null;
|
||||
activeTab: WorkspaceDerivedTab | null;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId;
|
||||
}
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (provider === "claude") {
|
||||
return "Claude";
|
||||
}
|
||||
if (provider === "codex") {
|
||||
return "Codex";
|
||||
}
|
||||
if (!provider) {
|
||||
return "Agent";
|
||||
}
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
|
||||
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
|
||||
if (!tab || typeof tab !== "object") {
|
||||
return null;
|
||||
}
|
||||
const tabId = trimNonEmpty(tab.tabId);
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
if (!tab.target || typeof tab.target !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
const draftId = trimNonEmpty(tab.target.draftId);
|
||||
if (!draftId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "draft", draftId },
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "agent") {
|
||||
const agentId = trimNonEmpty(tab.target.agentId);
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(tab.target.terminalId);
|
||||
if (!terminalId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "file") {
|
||||
const path = trimNonEmpty(tab.target.path);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "file", path: path.replace(/\\/g, "/") },
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
|
||||
if (target.kind === "draft") {
|
||||
return target.draftId;
|
||||
}
|
||||
if (target.kind === "agent") {
|
||||
return `agent_${target.agentId}`;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
return buildDeterministicWorkspaceTabId(target);
|
||||
}
|
||||
|
||||
export function deriveWorkspaceTabModel(input: {
|
||||
workspaceAgents: Agent[];
|
||||
terminals: TerminalLike[];
|
||||
tabs: WorkspaceTab[];
|
||||
tabOrder: string[];
|
||||
focusedTabId?: string | null;
|
||||
preferredTarget?: WorkspaceTabTarget | null;
|
||||
}): WorkspaceTabModel {
|
||||
const tabsById = new Map<string, WorkspaceDerivedTab>();
|
||||
const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent]));
|
||||
const terminalsById = new Map(input.terminals.map((terminal) => [terminal.id, terminal]));
|
||||
|
||||
const normalizedTabs = input.tabs
|
||||
.map((tab) => normalizeWorkspaceTab(tab))
|
||||
.filter((tab): tab is WorkspaceTab => tab !== null)
|
||||
.sort((left, right) => left.createdAt - right.createdAt);
|
||||
|
||||
for (const tab of normalizedTabs) {
|
||||
if (tab.target.kind === "draft") {
|
||||
tabsById.set(tab.tabId, {
|
||||
target: tab.target,
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: "draft",
|
||||
draftId: tab.target.draftId,
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tab.target.kind === "agent") {
|
||||
const agent = agentsById.get(tab.target.agentId) ?? null;
|
||||
const label = resolveWorkspaceAgentTabLabel(agent?.title);
|
||||
const provider = agent?.provider ?? "codex";
|
||||
tabsById.set(tab.tabId, {
|
||||
target: tab.target,
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: "agent",
|
||||
agentId: tab.target.agentId,
|
||||
provider,
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tab.target.kind === "terminal") {
|
||||
const terminal = terminalsById.get(tab.target.terminalId) ?? null;
|
||||
tabsById.set(tab.tabId, {
|
||||
target: tab.target,
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: "terminal",
|
||||
terminalId: tab.target.terminalId,
|
||||
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
|
||||
subtitle: "Terminal",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tab.target.kind === "file") {
|
||||
const filePath = tab.target.path;
|
||||
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
|
||||
tabsById.set(tab.tabId, {
|
||||
target: tab.target,
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: "file",
|
||||
filePath,
|
||||
label: fileName,
|
||||
subtitle: filePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const orderedTabIds: string[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const tabId of input.tabOrder) {
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedTabId || used.has(normalizedTabId) || !tabsById.has(normalizedTabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(normalizedTabId);
|
||||
orderedTabIds.push(normalizedTabId);
|
||||
}
|
||||
|
||||
for (const tabId of tabsById.keys()) {
|
||||
if (used.has(tabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(tabId);
|
||||
orderedTabIds.push(tabId);
|
||||
}
|
||||
|
||||
const tabs = orderedTabIds
|
||||
.map((tabId) => tabsById.get(tabId) ?? null)
|
||||
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
|
||||
|
||||
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
|
||||
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||
const preferredTarget = input.preferredTarget ?? null;
|
||||
const preferredTabId = (() => {
|
||||
if (!preferredTarget) {
|
||||
return null;
|
||||
}
|
||||
const matchingTab =
|
||||
tabs.find((tab) => tabTargetsEqual(tab.target, preferredTarget)) ?? null;
|
||||
return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget);
|
||||
})();
|
||||
|
||||
const activeTabId =
|
||||
preferredTabId && openTabIds.has(preferredTabId)
|
||||
? preferredTabId
|
||||
: focusedTabId && openTabIds.has(focusedTabId)
|
||||
? focusedTabId
|
||||
: tabs[0]?.descriptor.tabId ?? null;
|
||||
|
||||
const activeTab = activeTabId
|
||||
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
|
||||
: null;
|
||||
|
||||
const paneState = deriveWorkspacePaneState({
|
||||
tabs: input.tabs,
|
||||
focusedTabId: input.focusedTabId,
|
||||
preferredTarget: input.preferredTarget,
|
||||
});
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
tabs: paneState.tabs,
|
||||
activeTabId: paneState.activeTabId,
|
||||
activeTab: paneState.activeTab,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,51 +1,94 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import invariant from "tiny-invariant";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
deriveSidebarStateBucket,
|
||||
type SidebarStateBucket,
|
||||
} from "@/utils/sidebar-agent-state";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
|
||||
export type WorkspaceTabPresentation = {
|
||||
export interface WorkspaceTabPresentation {
|
||||
key: string;
|
||||
kind: WorkspaceTabDescriptor["kind"];
|
||||
label: string;
|
||||
subtitle: string;
|
||||
titleState: "ready" | "loading";
|
||||
provider: Agent["provider"] | null;
|
||||
icon: React.ComponentType<{ size: number; color: string }>;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
}
|
||||
|
||||
type WorkspaceTabPresentationResolverProps = {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
children: (presentation: WorkspaceTabPresentation) => ReactNode;
|
||||
};
|
||||
|
||||
export function deriveWorkspaceTabPresentation(input: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
agent?: Agent | null;
|
||||
}): WorkspaceTabPresentation {
|
||||
const { tab, agent = null } = input;
|
||||
return {
|
||||
key: tab.key,
|
||||
kind: tab.kind,
|
||||
label: tab.label,
|
||||
subtitle: tab.subtitle,
|
||||
titleState: tab.kind === "agent" ? tab.titleState : "ready",
|
||||
provider: tab.kind === "agent" ? tab.provider : null,
|
||||
statusBucket:
|
||||
tab.kind === "agent" && agent
|
||||
? deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
type WorkspaceTabPresentationResolverInnerProps = WorkspaceTabPresentationResolverProps & {
|
||||
registration: NonNullable<ReturnType<typeof getPanelRegistration>>;
|
||||
};
|
||||
|
||||
export function WorkspaceTabPresentationResolver({
|
||||
tab,
|
||||
serverId,
|
||||
workspaceId,
|
||||
children,
|
||||
}: WorkspaceTabPresentationResolverProps): ReactElement {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolverInner
|
||||
key={tab.kind}
|
||||
registration={registration}
|
||||
tab={tab}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceTabPresentationResolverInner>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceTabPresentationResolverInner({
|
||||
registration,
|
||||
tab,
|
||||
serverId,
|
||||
workspaceId,
|
||||
children,
|
||||
}: WorkspaceTabPresentationResolverInnerProps): ReactElement {
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const presentation = useMemo(
|
||||
() => ({
|
||||
key: tab.key,
|
||||
kind: tab.kind,
|
||||
label: descriptor.label,
|
||||
subtitle: descriptor.subtitle,
|
||||
titleState: descriptor.titleState,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
}),
|
||||
[
|
||||
descriptor.icon,
|
||||
descriptor.label,
|
||||
descriptor.statusBucket,
|
||||
descriptor.subtitle,
|
||||
descriptor.titleState,
|
||||
tab.key,
|
||||
tab.kind,
|
||||
]
|
||||
);
|
||||
|
||||
return <>{children(presentation)}</>;
|
||||
}
|
||||
|
||||
type WorkspaceTabIconProps = {
|
||||
@@ -74,49 +117,32 @@ export function WorkspaceTabIcon({
|
||||
const shouldShowLoader = shouldRenderSyncedStatusLoader({
|
||||
bucket: presentation.statusBucket,
|
||||
});
|
||||
const Icon = presentation.icon;
|
||||
|
||||
if (presentation.kind === "agent") {
|
||||
if (shouldShowLoader) {
|
||||
return (
|
||||
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
|
||||
<SyncedLoader size={size - 1} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowLoader) {
|
||||
return (
|
||||
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
|
||||
{presentation.provider === "claude" ? (
|
||||
<ClaudeIcon size={size} color={iconColor} />
|
||||
) : presentation.provider === "codex" ? (
|
||||
<CodexIcon size={size} color={iconColor} />
|
||||
) : (
|
||||
<Bot size={size} color={iconColor} />
|
||||
)}
|
||||
{statusDotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<SyncedLoader size={size - 1} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (presentation.kind === "draft") {
|
||||
return <Pencil size={size} color={iconColor} />;
|
||||
}
|
||||
|
||||
if (presentation.kind === "file") {
|
||||
return <FileText size={size} color={iconColor} />;
|
||||
}
|
||||
|
||||
return <Terminal size={size} color={iconColor} />;
|
||||
return (
|
||||
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
|
||||
<Icon size={size} color={iconColor} />
|
||||
{statusDotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type WorkspaceTabOptionRowProps = {
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type WorkspaceTabDescriptor =
|
||||
| {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: "draft";
|
||||
draftId: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: "agent";
|
||||
agentId: string;
|
||||
provider: Agent["provider"];
|
||||
label: string;
|
||||
subtitle: string;
|
||||
titleState: "ready" | "loading";
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: "terminal";
|
||||
terminalId: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: "file";
|
||||
filePath: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
};
|
||||
export interface WorkspaceTabDescriptor {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: WorkspaceTabTarget["kind"];
|
||||
target: WorkspaceTabTarget;
|
||||
}
|
||||
|
||||
@@ -9,33 +9,13 @@ beforeEach(() => {
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
visibleWorkspaceTargets: [],
|
||||
workspaceTabActionRequest: null,
|
||||
nextWorkspaceTabActionRequestId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard-shortcuts-store", () => {
|
||||
it("queues and clears workspace tab action requests", () => {
|
||||
useKeyboardShortcutsStore.getState().requestWorkspaceTabAction({
|
||||
serverId: "srv-1",
|
||||
workspaceId: "/repo/main",
|
||||
kind: "navigate-index",
|
||||
index: 3,
|
||||
});
|
||||
|
||||
const request = useKeyboardShortcutsStore.getState().workspaceTabActionRequest;
|
||||
expect(request).toEqual({
|
||||
id: 1,
|
||||
serverId: "srv-1",
|
||||
workspaceId: "/repo/main",
|
||||
kind: "navigate-index",
|
||||
index: 3,
|
||||
});
|
||||
|
||||
useKeyboardShortcutsStore.getState().clearWorkspaceTabActionRequest(999);
|
||||
expect(useKeyboardShortcutsStore.getState().workspaceTabActionRequest).not.toBeNull();
|
||||
|
||||
useKeyboardShortcutsStore.getState().clearWorkspaceTabActionRequest(1);
|
||||
expect(useKeyboardShortcutsStore.getState().workspaceTabActionRequest).toBeNull();
|
||||
it("toggles command center open state", () => {
|
||||
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(false);
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true);
|
||||
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
||||
|
||||
export type WorkspaceTabActionRequest =
|
||||
| {
|
||||
id: number;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "new" | "close-current";
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "navigate-index";
|
||||
index: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "navigate-relative";
|
||||
delta: 1 | -1;
|
||||
};
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
projectPickerOpen: boolean;
|
||||
@@ -33,8 +11,6 @@ interface KeyboardShortcutsState {
|
||||
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||
/** All visible workspace targets in top-to-bottom visual order. */
|
||||
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||
workspaceTabActionRequest: WorkspaceTabActionRequest | null;
|
||||
nextWorkspaceTabActionRequestId: number;
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setProjectPickerOpen: (open: boolean) => void;
|
||||
@@ -44,30 +20,10 @@ interface KeyboardShortcutsState {
|
||||
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||
resetModifiers: () => void;
|
||||
|
||||
requestWorkspaceTabAction: (input:
|
||||
| {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "new" | "close-current";
|
||||
}
|
||||
| {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "navigate-index";
|
||||
index: number;
|
||||
}
|
||||
| {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "navigate-relative";
|
||||
delta: 1 | -1;
|
||||
}) => void;
|
||||
clearWorkspaceTabActionRequest: (id: number) => void;
|
||||
}
|
||||
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
(set, get) => ({
|
||||
(set) => ({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
@@ -75,8 +31,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
visibleWorkspaceTargets: [],
|
||||
workspaceTabActionRequest: null,
|
||||
nextWorkspaceTabActionRequestId: 1,
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
|
||||
@@ -87,23 +41,5 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
set({ sidebarShortcutWorkspaceTargets: targets }),
|
||||
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
|
||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||
|
||||
requestWorkspaceTabAction: (input) => {
|
||||
const id = get().nextWorkspaceTabActionRequestId;
|
||||
set({
|
||||
workspaceTabActionRequest: {
|
||||
...input,
|
||||
id,
|
||||
} as WorkspaceTabActionRequest,
|
||||
nextWorkspaceTabActionRequestId: id + 1,
|
||||
});
|
||||
},
|
||||
clearWorkspaceTabActionRequest: (id) => {
|
||||
const current = get().workspaceTabActionRequest;
|
||||
if (!current || current.id !== id) {
|
||||
return;
|
||||
}
|
||||
set({ workspaceTabActionRequest: null });
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
type NavigationRouteLike = {
|
||||
name?: unknown;
|
||||
params?: unknown;
|
||||
path?: unknown;
|
||||
};
|
||||
|
||||
interface NavigationObserverRef {
|
||||
current: {
|
||||
getCurrentRoute(): unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
let snapshot: ActiveWorkspaceSelection | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): ActiveWorkspaceSelection | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
|
||||
if (
|
||||
snapshot?.serverId === next?.serverId &&
|
||||
snapshot?.workspaceId === next?.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
snapshot = next;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function extractActiveWorkspaceFromRoute(route: NavigationRouteLike | undefined): ActiveWorkspaceSelection | null {
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof route.path === "string") {
|
||||
const parsed = parseHostWorkspaceRouteFromPathname(route.path);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const params =
|
||||
route.params && typeof route.params === "object"
|
||||
? (route.params as {
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
})
|
||||
: null;
|
||||
const serverValue = Array.isArray(params?.serverId) ? params?.serverId[0] : params?.serverId;
|
||||
const workspaceValue = Array.isArray(params?.workspaceId)
|
||||
? params?.workspaceId[0]
|
||||
: params?.workspaceId;
|
||||
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
|
||||
const workspaceId =
|
||||
typeof workspaceValue === "string"
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
export function syncNavigationActiveWorkspace(
|
||||
navigationRef: NavigationObserverRef
|
||||
) {
|
||||
emitIfChanged(
|
||||
extractActiveWorkspaceFromRoute(navigationRef.current?.getCurrentRoute() as NavigationRouteLike | undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export interface ExplorerCheckoutContext {
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;
|
||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
|
||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
|
||||
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
|
||||
@@ -122,7 +122,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
// Desktop defaults based on platform
|
||||
desktop: {
|
||||
agentListOpen: DEFAULT_DESKTOP_OPEN,
|
||||
fileExplorerOpen: DEFAULT_DESKTOP_OPEN,
|
||||
fileExplorerOpen: false,
|
||||
},
|
||||
|
||||
// File explorer defaults
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,637 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
getTreeDepth,
|
||||
insertSplit,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/worktree";
|
||||
|
||||
function createTab(tabId: string): WorkspaceTab {
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "draft", draftId: tabId },
|
||||
createdAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createPane(input: {
|
||||
id: string;
|
||||
tabIds: string[];
|
||||
focusedTabId?: string | null;
|
||||
}): SplitNode {
|
||||
const tabs = input.tabIds.map((tabId) => createTab(tabId));
|
||||
return {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: input.id,
|
||||
tabIds: input.tabIds,
|
||||
focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null,
|
||||
tabs,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceKey(): string {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
expect(key).toBeTruthy();
|
||||
return key as string;
|
||||
}
|
||||
|
||||
function expectGroup(node: SplitNode): Extract<SplitNode, { kind: "group" }> {
|
||||
expect(node.kind).toBe("group");
|
||||
return node as Extract<SplitNode, { kind: "group" }>;
|
||||
}
|
||||
|
||||
describe("workspace-layout-store helpers", () => {
|
||||
it("finds panes and tabs across nested groups", () => {
|
||||
const root: SplitNode = {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.4, 0.6],
|
||||
children: [
|
||||
createPane({ id: "left", tabIds: ["tab-a", "tab-b"], focusedTabId: "tab-a" }),
|
||||
{
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-right",
|
||||
direction: "vertical",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
createPane({ id: "top-right", tabIds: ["tab-c"] }),
|
||||
createPane({ id: "bottom-right", tabIds: ["tab-d"] }),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(findPaneById(root, "top-right")?.tabIds).toEqual(["tab-c"]);
|
||||
expect(findPaneContainingTab(root, "tab-b")?.id).toBe("left");
|
||||
expect(getTreeDepth(root)).toBe(3);
|
||||
expect(collectAllPanes(root).map((pane) => pane.id)).toEqual([
|
||||
"left",
|
||||
"top-right",
|
||||
"bottom-right",
|
||||
]);
|
||||
expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual([
|
||||
"tab-a",
|
||||
"tab-b",
|
||||
"tab-c",
|
||||
"tab-d",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-layout-store tree transforms", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
const root: SplitNode = {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.25, 0.75],
|
||||
children: [
|
||||
createPane({ id: "left", tabIds: ["tab-a"] }),
|
||||
createPane({ id: "right", tabIds: ["tab-b", "tab-c"] }),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
||||
const nextGroup = expectGroup(nextRoot);
|
||||
const nestedGroup = expectGroup(nextGroup.group.children[1]!);
|
||||
|
||||
expect(nextGroup.group.direction).toBe("horizontal");
|
||||
expect(nextGroup.group.children).toHaveLength(2);
|
||||
expect(nextGroup.group.sizes).toEqual([0.25, 0.75]);
|
||||
expect(nestedGroup.group.id).toBe("group_22222222-2222-2222-2222-222222222222");
|
||||
expect(nestedGroup.group.direction).toBe("horizontal");
|
||||
expect(nestedGroup.group.sizes).toEqual([0.5, 0.5]);
|
||||
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([
|
||||
"left",
|
||||
"right",
|
||||
"pane_11111111-1111-1111-1111-111111111111",
|
||||
]);
|
||||
expect(findPaneById(nextRoot, "right")?.tabIds).toEqual(["tab-b"]);
|
||||
expect(findPaneById(nextRoot, "pane_11111111-1111-1111-1111-111111111111")?.tabIds).toEqual([
|
||||
"tab-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removePaneFromTree unwraps single-child groups and renormalizes siblings", () => {
|
||||
const root: SplitNode = {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.2, 0.8],
|
||||
children: [
|
||||
createPane({ id: "left", tabIds: ["tab-a"] }),
|
||||
{
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-right",
|
||||
direction: "vertical",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
createPane({ id: "top-right", tabIds: ["tab-b"] }),
|
||||
createPane({ id: "bottom-right", tabIds: ["tab-c"] }),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const nextRoot = removePaneFromTree(root, "top-right");
|
||||
const nextGroup = expectGroup(nextRoot);
|
||||
|
||||
expect(nextGroup.group.sizes).toEqual([0.2, 0.8]);
|
||||
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual(["left", "bottom-right"]);
|
||||
expect(nextGroup.group.children[1]).toEqual(createPane({ id: "bottom-right", tabIds: ["tab-c"] }));
|
||||
});
|
||||
|
||||
it("removeTabFromTree collapses empty panes but keeps the final root pane", () => {
|
||||
const splitRoot: SplitNode = {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
createPane({ id: "left", tabIds: ["tab-a"] }),
|
||||
createPane({ id: "right", tabIds: ["tab-b"] }),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const collapsed = removeTabFromTree(splitRoot, "tab-a");
|
||||
expect(collapsed).toEqual(createPane({ id: "right", tabIds: ["tab-b"] }));
|
||||
|
||||
const singlePaneRoot = createPane({ id: "main", tabIds: ["tab-a"] });
|
||||
const emptied = removeTabFromTree(singlePaneRoot, "tab-a");
|
||||
expect(emptied).toEqual(createPane({ id: "main", tabIds: [], focusedTabId: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-layout-store actions", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: secondTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
expect(splitPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
|
||||
store.focusPane(workspaceKey, "main");
|
||||
const duplicateTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
||||
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
|
||||
expect(duplicateTabId).toBe(secondTabId);
|
||||
expect(layout.focusedPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
|
||||
"file_/repo/worktree/a.ts",
|
||||
"file_/repo/worktree/b.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const fileTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const terminalTabId = store.openTab(workspaceKey, { kind: "terminal", terminalId: "term-1" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: terminalTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
store.focusTab(workspaceKey, fileTabId!);
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusTab(workspaceKey, terminalTabId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId);
|
||||
});
|
||||
|
||||
it("retargetTab updates the existing tab target without moving it to a different pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"12121212-1212-1212-1212-121212121212"
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: secondTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const nextTabId = store.retargetTab(workspaceKey, secondTabId!, {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const splitPane = findPaneById(layout.root, splitPaneId!);
|
||||
const retargetedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === secondTabId);
|
||||
|
||||
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
||||
expect(nextTabId).toBe(secondTabId);
|
||||
expect(splitPane?.tabIds).toEqual([secondTabId!]);
|
||||
expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId);
|
||||
expect(retargetedTab).toEqual({
|
||||
tabId: secondTabId,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("reorderTabs reorders tabs within the focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
|
||||
|
||||
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(findPaneById(layout.root, "main")).toEqual({
|
||||
id: "main",
|
||||
tabIds: [thirdTabId!, firstTabId!, secondTabId!],
|
||||
focusedTabId: thirdTabId,
|
||||
tabs: [
|
||||
{ tabId: thirdTabId, target: { kind: "file", path: "/repo/worktree/c.ts" }, createdAt: expect.any(Number) },
|
||||
{ tabId: firstTabId, target: { kind: "file", path: "/repo/worktree/a.ts" }, createdAt: expect.any(Number) },
|
||||
{ tabId: secondTabId, target: { kind: "file", path: "/repo/worktree/b.ts" }, createdAt: expect.any(Number) },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"34343434-3434-3434-3434-343434343434"
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
|
||||
const fourthTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: thirdTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
|
||||
store.focusPane(workspaceKey, "main");
|
||||
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(findPaneById(layout.root, splitPaneId!)).toEqual({
|
||||
id: splitPaneId,
|
||||
tabIds: [fourthTabId!, thirdTabId!],
|
||||
focusedTabId: fourthTabId,
|
||||
tabs: [
|
||||
{ tabId: fourthTabId, target: { kind: "file", path: "/repo/worktree/d.ts" }, createdAt: expect.any(Number) },
|
||||
{ tabId: thirdTabId, target: { kind: "file", path: "/repo/worktree/c.ts" }, createdAt: expect.any(Number) },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("focusPane switches workspace focus to a different pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"56565656-5656-5656-5656-565656565656"
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: secondTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
store.focusPane(workspaceKey, "main");
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusPane(workspaceKey, splitPaneId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
});
|
||||
|
||||
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"cccccccc-cccc-cccc-cccc-cccccccccccc"
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: secondTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
|
||||
});
|
||||
|
||||
it("splitPane enforces the maximum depth of four", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
|
||||
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
|
||||
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
|
||||
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
|
||||
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
|
||||
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
|
||||
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const a = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const b = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const c = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
|
||||
const d = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" });
|
||||
const e = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/e.ts" });
|
||||
|
||||
expect(a).toBeTruthy();
|
||||
const pane1 = store.splitPane(workspaceKey, {
|
||||
tabId: b!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
const pane2 = store.splitPane(workspaceKey, {
|
||||
tabId: c!,
|
||||
targetPaneId: pane1!,
|
||||
position: "bottom",
|
||||
});
|
||||
const pane3 = store.splitPane(workspaceKey, {
|
||||
tabId: d!,
|
||||
targetPaneId: pane2!,
|
||||
position: "right",
|
||||
});
|
||||
const pane4 = store.splitPane(workspaceKey, {
|
||||
tabId: e!,
|
||||
targetPaneId: pane3!,
|
||||
position: "bottom",
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
|
||||
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
|
||||
expect(pane4).toBeNull();
|
||||
expect(getTreeDepth(layout.root)).toBe(4);
|
||||
});
|
||||
|
||||
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"dddddddd-dddd-dddd-dddd-dddddddddddd"
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const leftTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const rightTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: rightTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
|
||||
expect(findPaneById(layout.root, splitPaneId!)?.tabIds).toEqual([
|
||||
"file_/repo/worktree/b.ts",
|
||||
"file_/repo/worktree/a.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
|
||||
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
|
||||
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
|
||||
const paneBId = store.splitPane(workspaceKey, {
|
||||
tabId: secondTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
const paneCId = store.splitPane(workspaceKey, {
|
||||
tabId: thirdTabId!,
|
||||
targetPaneId: paneBId!,
|
||||
position: "bottom",
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const rootGroup = expectGroup(layout.root);
|
||||
|
||||
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
||||
expect(paneCId).toBe("pane_9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
|
||||
expect(layout.focusedPaneId).toBe(paneCId);
|
||||
expect(rootGroup.group.direction).toBe("horizontal");
|
||||
expect(rootGroup.group.children).toHaveLength(2);
|
||||
expect(
|
||||
rootGroup.group.children.map((child) => {
|
||||
expect(child.kind).toBe("pane");
|
||||
if (child.kind !== "pane") {
|
||||
throw new Error("Expected pane child");
|
||||
}
|
||||
return {
|
||||
id: child.pane.id,
|
||||
tabIds: child.pane.tabIds,
|
||||
focusedTabId: child.pane.focusedTabId,
|
||||
};
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
id: "main",
|
||||
tabIds: ["file_/repo/worktree/a.ts"],
|
||||
focusedTabId: "file_/repo/worktree/a.ts",
|
||||
},
|
||||
{
|
||||
id: paneCId!,
|
||||
tabIds: ["file_/repo/worktree/c.ts"],
|
||||
focusedTabId: "file_/repo/worktree/c.ts",
|
||||
},
|
||||
]);
|
||||
expect(rootGroup.group.sizes).toEqual([0.5, 0.5]);
|
||||
});
|
||||
|
||||
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"abababab-abab-abab-abab-abababababab"
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: secondTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
store.focusPane(workspaceKey, "main");
|
||||
const duplicateTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
|
||||
expect(duplicateTabId).toBe(secondTabId);
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
|
||||
"file_/repo/worktree/a.ts",
|
||||
"file_/repo/worktree/b.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
|
||||
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const a = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const b = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const c = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
|
||||
|
||||
expect(a).toBeTruthy();
|
||||
const rightPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: b!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
const farRightPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: c!,
|
||||
targetPaneId: rightPaneId!,
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
||||
const splitGroup = expectGroup(splitRoot);
|
||||
const nestedGroup = expectGroup(splitGroup.group.children[1]!);
|
||||
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
|
||||
|
||||
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
||||
const resizedGroup = expectGroup(resizedRoot);
|
||||
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]!);
|
||||
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
||||
|
||||
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||
expect(farRightPaneId).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||
expect(resizedNestedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
|
||||
expect(resizedNestedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
|
||||
expect(total).toBeCloseTo(1, 10);
|
||||
});
|
||||
|
||||
it("closing the last tab keeps a single empty pane in the layout", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const tabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.closeTab(workspaceKey, tabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(layout).toEqual(createDefaultLayout());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTab,
|
||||
type WorkspaceTabTarget,
|
||||
} from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
clampNormalizedSizes,
|
||||
closeTabInLayout,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
focusPaneInLayout,
|
||||
focusTabInLayout,
|
||||
getTreeDepth,
|
||||
insertSplit,
|
||||
moveTabToPaneInLayout,
|
||||
normalizeLayout,
|
||||
openTabInLayout,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
reorderFocusedPaneTabsInLayout,
|
||||
reorderPaneTabsInLayout,
|
||||
retargetTabInLayout,
|
||||
splitPaneEmptyInLayout,
|
||||
splitPaneInLayout,
|
||||
type SplitGroup,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
|
||||
|
||||
export { buildWorkspaceTabPersistenceKey };
|
||||
export {
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
getTreeDepth,
|
||||
insertSplit,
|
||||
normalizeLayout,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
};
|
||||
export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout };
|
||||
|
||||
interface WorkspaceLayoutStore {
|
||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
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;
|
||||
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
|
||||
splitPane: (
|
||||
workspaceKey: string,
|
||||
input: {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}
|
||||
) => string | null;
|
||||
splitPaneEmpty: (
|
||||
workspaceKey: string,
|
||||
input: {
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}
|
||||
) => string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
const MAX_TREE_DEPTH = 4;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function getWorkspaceLayout(state: Record<string, WorkspaceLayout>, workspaceKey: string): WorkspaceLayout {
|
||||
return normalizeLayout(state[workspaceKey] ?? createDefaultLayout());
|
||||
}
|
||||
|
||||
export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
openTab: (workspaceKey, target) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = openTabInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
closeTab: (workspaceKey, tabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = closeTabInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
focusTab: (workspaceKey, tabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = focusTabInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
retargetTab: (workspaceKey, tabId, target) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = retargetTabInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
target: normalizedTarget,
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
reorderTabs: (workspaceKey, tabIds) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = reorderFocusedPaneTabsInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabIds,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
getWorkspaceTabs: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return [];
|
||||
}
|
||||
return collectAllTabs(getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey).root);
|
||||
},
|
||||
splitPane: (workspaceKey, input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(input.tabId);
|
||||
const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTargetPaneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = splitPaneInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
targetPaneId: normalizedTargetPaneId,
|
||||
position: input.position,
|
||||
maxTreeDepth: MAX_TREE_DEPTH,
|
||||
createNodeId: (prefix) => {
|
||||
const randomValue =
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}_${randomValue}`;
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.paneId;
|
||||
},
|
||||
splitPaneEmpty: (workspaceKey, input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTargetPaneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = splitPaneEmptyInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
targetPaneId: normalizedTargetPaneId,
|
||||
position: input.position,
|
||||
maxTreeDepth: MAX_TREE_DEPTH,
|
||||
createNodeId: (prefix) => {
|
||||
const randomValue =
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}_${randomValue}`;
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.paneId;
|
||||
},
|
||||
moveTabToPane: (workspaceKey, tabId, toPaneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedToPaneId = trimNonEmpty(toPaneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedToPaneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = moveTabToPaneInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
toPaneId: normalizedToPaneId,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
focusPane: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedPaneId = trimNonEmpty(paneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = focusPaneInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
paneId: normalizedPaneId,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
resizeSplit: (workspaceKey, groupId, sizes) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedGroupId = trimNonEmpty(groupId);
|
||||
if (!normalizedWorkspaceKey || !normalizedGroupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
splitSizesByWorkspace: {
|
||||
...state.splitSizesByWorkspace,
|
||||
[normalizedWorkspaceKey]: {
|
||||
...(state.splitSizesByWorkspace[normalizedWorkspaceKey] ?? {}),
|
||||
[normalizedGroupId]: clampNormalizedSizes(sizes),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
reorderTabsInPane: (workspaceKey, paneId, tabIds) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedPaneId = trimNonEmpty(paneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = reorderPaneTabsInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
paneId: normalizedPaneId,
|
||||
tabIds,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "workspace-layout-state",
|
||||
version: 1,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => {
|
||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||
for (const key in state.layoutByWorkspace) {
|
||||
layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]);
|
||||
}
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user