Compare commits

...

37 Commits

Author SHA1 Message Date
Mohamed Boudra
bacfb6352e Use Ubuntu 24.04 for Linux AppImage build 2026-03-19 13:59:41 +07:00
Mohamed Boudra
08f18da22d Fix Linux AppImage WebKitGTK packaging 2026-03-19 13:38:16 +07:00
Mohamed Boudra
4ab307b10f Add fetch tool details and improve sidebar shortcuts handling 2026-03-19 13:04:44 +07:00
Mohamed Boudra
675d825f6b chore(release): cut 0.1.30 2026-03-19 08:59:23 +07:00
Mohamed Boudra
1328e0cc05 docs(changelog): add 0.1.30 release notes 2026-03-19 08:58:33 +07:00
Mohamed Boudra
f0ba64f23b Add combined model selector and refactor working indicator 2026-03-19 08:55:40 +07:00
Mohamed Boudra
22c27dd583 Fix UI spacing and sizing in status bar and message input 2026-03-18 23:13:19 +07:00
Mohamed Boudra
35da664a09 Add agent mode visuals (icons and color tiers) to UI components 2026-03-18 22:54:09 +07:00
Mohamed Boudra
1323cb13c4 Add agent mode visuals (icons and color tiers) to UI components 2026-03-18 22:31:31 +07:00
Mohamed Boudra
3287f2d00b Only show bg on expanded file headers in git diff panel 2026-03-18 22:07:34 +07:00
Mohamed Boudra
d15a1451b7 Track active workspace from navigation state 2026-03-18 21:50:24 +07:00
Mohamed Boudra
9d370a18b8 Clean up git diff panel file header styles 2026-03-18 21:50:09 +07:00
Mohamed Boudra
865e25b3a9 Fix workspace tab drag hit area 2026-03-18 21:11:09 +07:00
Mohamed Boudra
82ab598426 Fix workspace route state syncing 2026-03-18 21:11:00 +07:00
Mohamed Boudra
09fae62888 Tighten terminal scrollbar gutter 2026-03-18 21:10:51 +07:00
Mohamed Boudra
7e0a220fde feat: improve terminal scrollbar and pane focus handling 2026-03-18 20:39:16 +07:00
Mohamed Boudra
ed9a15c0bd feat: add tab drop preview indicator for split pane drag-and-drop 2026-03-18 20:09:28 +07:00
Mohamed Boudra
b763d4358e feat: add terminal tabs and split pane controls to workspace UI 2026-03-18 19:55:04 +07:00
Mohamed Boudra
5f2d4ac122 chore(release): sync 0.1.29 versions 2026-03-18 19:47:30 +07:00
Mohamed Boudra
3bdc90a661 refactor: simplify tab layout to use equal widths with ideal clamping 2026-03-18 16:59:03 +07:00
Mohamed Boudra
b5212a69c9 refactor: improve split pane drag-and-drop and resize handle UX 2026-03-18 16:19:43 +07:00
Mohamed Boudra
0bc903fa21 Fix agent spawn PATH resolution and add provider binary checks to status
Shell env PATH was being overridden by process.env PATH in
applyProviderEnv, causing agent spawns to fail with ENOENT when the
daemon runs from the Tauri desktop app (minimal GUI PATH). Flip the
merge order so login shell env wins.

Add a Providers section to `paseo daemon status` that resolves each
agent binary (claude, codex, opencode) and runs --version using the
same applyProviderEnv environment the daemon uses to spawn agents.
2026-03-18 10:08:48 +07:00
Mohamed Boudra
e2f20f0e24 refactor: extract workspace layout actions and pane state logic 2026-03-18 09:39:12 +07:00
Mohamed Boudra
30a225ce9f Add split-pane layout store and navigation shortcuts 2026-03-18 01:36:54 +07:00
Mohamed Boudra
30dd54a318 refactor: extract panel registry and pane context from workspace screen
Replace hardcoded renderContent() switch and per-kind descriptor logic
with a registry-based panel interface. Each panel type (agent, draft,
terminal, file) now self-registers with its component, useDescriptor
hook, and optional confirmClose. Panels access workspace context via
usePaneContext() instead of prop drilling. This prepares the workspace
screen for split pane support.
2026-03-17 21:58:11 +07:00
Mohamed Boudra
abc8ad3fd4 refactor: migrate workspace tab actions to keyboard action dispatcher 2026-03-17 20:19:41 +07:00
Mohamed Boudra
4897627943 Fix Tauri tab close confirm reentry 2026-03-17 16:14:18 +07:00
Mohamed Boudra
18533ef52b style(app): add padding to terminal emulator 2026-03-17 14:51:17 +07:00
Mohamed Boudra
faa9c9c491 fix(app): start explorer panel closed and narrower on clean start 2026-03-17 14:20:32 +07:00
Mohamed Boudra
11f6494c20 fix: route Claude partial tool input through canonical mapping (#108) (#122)
* fix: show loading skeleton for pending tool call details

* fix: surface claude tool previews before full json

* fix: route claude partial tool input through canonical mapping

* fix: remove pending tool-call summary placeholder
2026-03-17 14:42:39 +08:00
Mohamed Boudra
9364da3414 fix(website): fix horizontal scroll on mobile
The APK tooltip with whitespace-nowrap and centered positioning
overflowed past the viewport on narrow screens. Anchor it to the
right on mobile, center it on sm+. Also stack nav logo and menu
vertically on mobile.
2026-03-16 18:44:31 +07:00
Mohamed Boudra
ed5bc3091e fix(app): shorten paths in project picker and close sidebar on workspace press 2026-03-15 20:16:41 +07:00
Mohamed Boudra
8ff51eb176 docs(release): add guide for retrying failed release builds 2026-03-15 14:34:15 +07:00
Mohamed Boudra
90fb5e1f33 fix(app): build workspace audio module during EAS install 2026-03-15 14:28:30 +07:00
Mohamed Boudra
cda08ec033 feat(website): add og:title and og:description via pageMeta helper
Extract a DRY pageMeta(title, description) helper that returns both
standard and OpenGraph meta tags from a single source of truth.
2026-03-15 14:28:10 +07:00
Mohamed Boudra
21c585abec chore(website): update OG image 2026-03-15 14:23:40 +07:00
Mohamed Boudra
a1d0492d8d feat(website): add OG image for social sharing
Copy og-image.png to public assets and add OpenGraph and Twitter Card
meta tags to the root layout so link previews work across all pages.
2026-03-15 14:15:51 +07:00
154 changed files with 11103 additions and 2647 deletions

View File

@@ -238,7 +238,7 @@ jobs:
permissions:
contents: write
packages: read
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

View File

@@ -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

341
docs/PANEL-REFACTOR-PLAN.md Normal file
View File

@@ -0,0 +1,341 @@
# Panel Interface Refactor Plan
**Goal:** Replace the hardcoded panel switch statements with a registry-based panel interface. This is a pure refactor — all product surfaces stay identical. The motivation is to prepare for split panes (VSCode-style), where each split independently renders panels.
## The Problem
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`, ~2084 lines) has a `renderContent()` function (line 1437) that switches on `target.kind` to render each panel type with bespoke props. The same pattern repeats in:
- `workspace-tab-model.ts` — switches on `target.kind` to build tab descriptors (labels, subtitles, status)
- `workspace-tab-presentation.tsx` — switches on kind for icons and status indicators
Every new panel type requires editing 3+ files. This must become a registry where panels self-register.
## Target Architecture
### 1. PanelRegistration Interface
```typescript
// panels/panel-registry.ts
interface PanelDescriptor {
label: string;
subtitle: string;
titleState: "ready" | "loading";
icon: React.ComponentType<{ size: number; color: string }>;
statusBucket: SidebarStateBucket | null;
}
interface PanelRegistration<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
kind: K;
component: React.ComponentType;
useDescriptor(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: { serverId: string; workspaceId: string },
): PanelDescriptor;
confirmClose?(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: { serverId: string; workspaceId: string },
): Promise<boolean>;
}
```
### 2. Panel Registry
```typescript
const panelRegistry = new Map<string, PanelRegistration>();
function registerPanel(registration: PanelRegistration): void {
panelRegistry.set(registration.kind, registration);
}
function getPanelRegistration(kind: string): PanelRegistration | undefined {
return panelRegistry.get(kind);
}
```
### 3. PaneContext
Every panel gets workspace-level context via `usePaneContext()`. No prop drilling of serverId/workspaceId through panel-specific props.
```typescript
interface PaneContextValue {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
retargetCurrentTab(target: WorkspaceTabTarget): void;
openFileInWorkspace(filePath: string): void;
}
```
### 4. WorkspaceTabTarget stays unchanged
```typescript
type WorkspaceTabTarget =
| { kind: "draft"; draftId: string }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string };
```
No store migration needed. `serverId` and `workspaceId` come from the pane context, not the target.
## Panel Implementations
Each panel type gets its own file that exports a `PanelRegistration`. Panels use `usePaneContext()` for workspace-level context and read their own data from stores directly.
### Agent Panel Example
```typescript
// panels/agent-panel.ts
function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const agent = useSessionStore(
(s) => s.agentsByServer.get(context.serverId)?.get(target.agentId) ?? null,
);
const provider = agent?.provider ?? "codex";
const label = resolveAgentLabel(agent?.title);
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: agentIconForProvider(provider),
statusBucket: agent ? deriveAgentStatusBucket(agent) : null,
};
}
function AgentPanel() {
const { serverId, target, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<AgentReadyScreen
serverId={serverId}
agentId={target.agentId}
showExplorerSidebar={false}
wrapWithExplorerSidebarProvider={false}
onOpenWorkspaceFile={openFileInWorkspace}
/>
);
}
export const agentPanelRegistration: PanelRegistration<"agent"> = {
kind: "agent",
component: AgentPanel,
useDescriptor: useAgentPanelDescriptor,
async confirmClose(target, context) {
const agent = useSessionStore.getState().agentsByServer.get(context.serverId)?.get(target.agentId);
if (agent?.status === "running") {
return confirmDialog({ title: "Agent is still running. Close anyway?" });
}
return true;
},
};
```
### Terminal Panel Example
```typescript
function useTerminalPanelDescriptor(
target: { kind: "terminal"; terminalId: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
// read terminal data from appropriate store
return {
label: "Terminal",
subtitle: "Terminal",
titleState: "ready",
icon: TerminalIcon,
statusBucket: null,
};
}
function TerminalPanel() {
const { serverId, workspaceId, target, openTab } = usePaneContext();
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
return (
<TerminalPane
serverId={serverId}
cwd={workspaceId}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (terminalId) {
openTab({ kind: "terminal", terminalId });
}
}}
hideHeader
manageTerminalDirectorySubscription={false}
/>
);
}
```
### Draft Panel Example
```typescript
function useDraftPanelDescriptor(
_target: { kind: "draft"; draftId: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
return {
label: "New Agent",
subtitle: "New Agent",
titleState: "ready",
icon: PencilIcon,
statusBucket: null,
};
}
function DraftPanel() {
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
<WorkspaceDraftAgentTab
serverId={serverId}
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
onOpenWorkspaceFile={openFileInWorkspace}
onCreated={(agentSnapshot) => {
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
}}
/>
);
}
```
### File Panel Example
```typescript
function useFilePanelDescriptor(
target: { kind: "file"; path: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
return {
label: fileName,
subtitle: target.path,
titleState: "ready",
icon: FileTextIcon,
statusBucket: null,
};
}
function FilePanel() {
const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "file", "FilePanel requires file target");
return (
<FilePane
serverId={serverId}
workspaceRoot={workspaceId}
filePath={target.path}
/>
);
}
```
## How the Tab Bar Uses It
Each tab chip calls the panel's `useDescriptor` hook:
```typescript
function TabChip({ tabId, target, serverId, workspaceId }: {
tabId: string;
target: WorkspaceTabTarget;
serverId: string;
workspaceId: string;
}) {
const registration = getPanelRegistration(target.kind);
invariant(registration, `No panel registration for kind: ${target.kind}`);
const descriptor = registration.useDescriptor(target, { serverId, workspaceId });
return (
<TabChipChrome
tabId={tabId}
label={descriptor.label}
subtitle={descriptor.subtitle}
titleState={descriptor.titleState}
icon={<descriptor.icon size={16} color={theme.colors.foregroundMuted} />}
statusBucket={descriptor.statusBucket}
/>
);
}
```
## How the Workspace Screen Renders Content
Replaces the entire `renderContent()` switch:
```typescript
function PaneContent({ tabId, target, serverId, workspaceId }: {
tabId: string;
target: WorkspaceTabTarget;
serverId: string;
workspaceId: string;
}) {
const registration = getPanelRegistration(target.kind);
if (!registration) return null;
const Component = registration.component;
return (
<PaneProvider value={{ serverId, workspaceId, tabId, target, ...actions }}>
<Component />
</PaneProvider>
);
}
```
## Implementation Steps
### Step 1: Create panel registry infrastructure
Create the following new files:
- `packages/app/src/panels/panel-registry.ts``PanelRegistration`, `PanelDescriptor` types, registry map, `registerPanel()`, `getPanelRegistration()`
- `packages/app/src/panels/pane-context.ts``PaneContextValue` type, React context, `PaneProvider`, `usePaneContext()` hook
### Step 2: Create panel registration files
Move panel-specific logic out of workspace-screen, workspace-tab-model, and workspace-tab-presentation into self-contained panel modules:
- `packages/app/src/panels/agent-panel.ts` — agent component wrapper + `useDescriptor` + `confirmClose`
- `packages/app/src/panels/draft-panel.ts` — draft component wrapper + `useDescriptor`
- `packages/app/src/panels/terminal-panel.ts` — terminal component wrapper + `useDescriptor`
- `packages/app/src/panels/file-panel.ts` — file component wrapper + `useDescriptor`
- `packages/app/src/panels/register-panels.ts` — imports all panels, calls `registerPanel()` for each
### Step 3: Refactor workspace-tab-model.ts
Replace the per-kind descriptor derivation in `deriveWorkspaceTabModel()` with calls to `getPanelRegistration(target.kind).useDescriptor(...)`.
Note: `deriveWorkspaceTabModel` is a pure function, not a hook. The `useDescriptor` hooks are called from React components (the tab bar). The model derivation may need to be restructured — the tab bar calls `useDescriptor` per tab, and the model just handles ordering and active-tab resolution.
### Step 4: Refactor workspace-screen.tsx renderContent()
Replace the `renderContent()` switch with `<PaneContent>` that uses the registry. Wire up the `PaneProvider` with the action callbacks that currently live as inline functions in the workspace screen.
### Step 5: Refactor workspace-tab-presentation.tsx
Move icon components and status derivation into each panel's registration. The shared `WorkspaceTabIcon` component becomes a thin wrapper that calls `registration.useDescriptor()` and renders the icon from the descriptor.
### Step 6: Verify
- `npm run typecheck` must pass
- All existing tab behavior must work identically: open, close, reorder, retarget, keyboard shortcuts, context menus
- Mobile tab switcher must work unchanged
- No visual regressions in tab bar, icons, status indicators
## Constraints
- **Pure refactor** — zero user-visible behavior changes
- **No new features** — no splits, no new panel types, no new keyboard shortcuts
- **WorkspaceTabTarget stays unchanged** — no store migration
- **workspace-tabs-store.ts stays unchanged** — the store is not part of this refactor
- **Do not create index.ts barrel files** — project convention
- **Use `invariant` from `tiny-invariant`** for asserting panel target kinds
- **Use `function` declarations** — project convention (no arrow function components)
- **Use `interface` over `type` where possible** — project convention
- **Run `npm run typecheck` after every change** — project rule

View File

@@ -32,6 +32,23 @@ npm run release:finalize # Publish npm, promote draft to published
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
## Fixing a failed release build
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
To retry a failed workflow for an existing tag:
1. **Retry via `workflow_dispatch`** — all release workflows support `workflow_dispatch` with a `tag` input:
```bash
gh workflow run "Desktop Release" -f tag=v0.1.28 # all platforms
gh workflow run "Desktop Release" -f tag=v0.1.28 -f platform=macos # single platform
gh workflow run "Android APK Release" -f tag=v0.1.28
gh workflow run "Deploy App" # no tag input needed
```
2. **Platform-specific retry tags** (desktop only) — push a tag like `desktop-macos-v0.1.28` to rebuild just that platform against the release tag's code
If the fix requires a code change (e.g. a broken build script), commit the fix to `main` and use `workflow_dispatch` pointing at the existing tag — the workflow checks out the tag ref, but for build-tooling fixes you may need to point it at `main` or cherry-pick the fix onto the tag.
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions

275
docs/SPLIT-PANES-PLAN.md Normal file
View File

@@ -0,0 +1,275 @@
# Split Panes Plan
**Goal:** VSCode-style split panes for the workspace screen. Users can drag tabs to edges to create horizontal/vertical splits, resize splits, and navigate between panes with keyboard shortcuts. Desktop/web only — mobile uses the same store but never creates splits (single pane).
## Data Model
### Core Types
```typescript
interface SplitPane {
id: string;
tabIds: string[];
focusedTabId: string | null;
}
interface SplitGroup {
id: string;
direction: "horizontal" | "vertical";
children: SplitNode[];
sizes: number[]; // proportional, sum to 1, same length as children
}
type SplitNode =
| { kind: "pane"; pane: SplitPane }
| { kind: "group"; group: SplitGroup };
interface WorkspaceLayout {
root: SplitNode;
focusedPaneId: string;
}
```
### Design Decisions
- **Single store replaces the flat tab store.** The layout store owns tabs, tab order (per pane), and focused tab (per pane). No separate flat tab store.
- **Mobile is just a single-pane tree.** Same store, same code paths. Mobile never calls split operations, so the tree never grows beyond one pane.
- **Focused pane concept.** Common operations (`openTab`, `closeTab`, `focusTab`) route to the focused pane automatically. No `paneId` parameter needed for everyday use.
- **`PaneContext` doesn't need `paneId`.** Split-specific operations (drag-drop, resize) are wired directly in split UI components that know their pane ID from tree rendering.
- **Max depth: 4 levels.**
- **Proportional sizes** that sum to 1. Minimum proportion per child: 0.1 (10%).
### Default State
Every workspace starts with:
```typescript
{
root: { kind: "pane", pane: { id: "main", tabIds: [], focusedTabId: null } },
focusedPaneId: "main",
}
```
### Migration
Version 6 migration from the current flat tab store. Wraps existing `tabIds`, `tabOrder`, and `focusedTabId` into a single-pane tree.
## Store Actions
### Everyday Operations (pane-agnostic)
These don't take a `paneId`. Mobile code only uses these.
```typescript
openTab(workspaceKey: string, target: WorkspaceTabTarget): string | null;
closeTab(workspaceKey: string, tabId: string): void;
focusTab(workspaceKey: string, tabId: string): void;
retargetTab(workspaceKey: string, tabId: string, target: WorkspaceTabTarget): string | null;
reorderTabs(workspaceKey: string, tabIds: string[]): void; // within focused pane
getWorkspaceTabs(workspaceKey: string): WorkspaceTab[]; // all tabs across all panes
```
- `openTab` creates the tab and adds it to the focused pane.
- `closeTab` finds the tab in any pane, removes it. If that was the last tab in the pane, collapses the pane.
- `focusTab` finds the tab in any pane, focuses it and focuses that pane.
### Split Operations (desktop only)
```typescript
splitPane(workspaceKey: string, input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}): string | null; // new pane ID, or null if depth cap hit
moveTabToPane(workspaceKey: string, tabId: string, toPaneId: string): void;
focusPane(workspaceKey: string, paneId: string): void;
resizeSplit(workspaceKey: string, groupId: string, sizes: number[]): void;
reorderTabsInPane(workspaceKey: string, paneId: string, tabIds: string[]): void;
```
## Tree Transformations
### splitPane
**Position mapping:**
- `left` / `right``horizontal` direction
- `top` / `bottom``vertical` direction
- `left` / `top` → new pane inserted before target
- `right` / `bottom` → new pane inserted after target
**Optimization:** If the target pane's parent group has the same direction, insert as a sibling into that group instead of nesting. This keeps the tree flat.
```
Before: horizontal([A, B])
Split B right with tab X
Optimized: horizontal([A, B, C]) ← insert into existing group
Naive: horizontal([A, horizontal([B, C])]) ← wastes depth
```
**Steps:**
1. Check depth — reject if would exceed 4 levels
2. Remove `tabId` from source pane (could be same or different pane)
3. Create new pane: `{ id: generateId(), tabIds: [tabId], focusedTabId: tabId }`
4. If parent group has same direction → insert new pane adjacent to target in parent's children, split target's size proportion 50/50 between target and new pane
5. Else → replace target node with new group `{ direction, children: [target, newPane], sizes: [0.5, 0.5] }` (order based on position)
6. If source pane is now empty → collapse it
7. Set `focusedPaneId` to new pane
### collapsePane
Triggered when a pane's last tab is removed or moved out.
```
Before: horizontal([A, B, C]) sizes [0.3, 0.4, 0.3]
B loses last tab
After: horizontal([A, C]) sizes [0.5, 0.5] (renormalized)
```
**Steps:**
1. Remove pane from parent group's children
2. Remove corresponding entry from parent's sizes
3. Renormalize sizes to sum to 1
4. If parent group now has 1 child → unwrap: replace group with its single remaining child
5. Unwrap can cascade up the tree
6. Move focus to nearest sibling
### moveTabToPane
Tab dragged from one pane to another existing pane.
1. Remove `tabId` from source pane's `tabIds`
2. Insert into target pane's `tabIds` at drop position (or end)
3. Set target pane's `focusedTabId` to the moved tab
4. If source pane is now empty → collapsePane
5. Set `focusedPaneId` to target pane
### resizeSplit
User drags a divider between panes.
1. Find group by ID
2. Update the two adjacent sizes based on drag delta
3. Clamp each child to minimum proportion (0.1)
4. Renormalize so sizes sum to 1
## Keyboard Shortcuts
| Action | Shortcut |
|---|---|
| Split right | `Cmd+\` |
| Split down | `Cmd+Shift+\` |
| Focus pane left | `Cmd+Shift+←` |
| Focus pane right | `Cmd+Shift+→` |
| Focus pane up | `Cmd+Shift+↑` |
| Focus pane down | `Cmd+Shift+↓` |
| Move tab to pane left | `Cmd+Shift+Alt+←` |
| Move tab to pane right | `Cmd+Shift+Alt+→` |
| Move tab to pane up | `Cmd+Shift+Alt+↑` |
| Move tab to pane down | `Cmd+Shift+Alt+↓` |
| Close pane | `Cmd+Shift+W` |
Existing tab shortcuts unchanged — `Cmd+T`, `Cmd+W`, `Alt+Shift+[/]`, `Alt+1-9` — they operate on the focused pane's tabs.
## Drag and Drop UX
### Drop Zones
When dragging a tab over a pane, the pane is divided into 5 drop zones:
- **Center** (inner 40%) — move tab to this pane (add to existing tab list)
- **Left edge** (leftmost 15%) — split left
- **Right edge** (rightmost 15%) — split right
- **Top edge** (topmost 15%) — split up
- **Bottom edge** (bottommost 15%) — split down
### Overlay Preview
On hover over a drop zone, show a semi-transparent overlay rectangle covering the half of the pane where the new split would appear. The overlay uses the theme's accent color at low opacity.
### Cross-Pane Tab Drag
Tabs can be dragged:
- Within a pane's tab bar → reorder (existing behavior via SortableInlineList)
- From one pane's tab bar to another pane's tab bar → move tab to that pane
- From a tab bar to a pane's drop zone → split
When dragging the last tab out of a pane, the pane collapses after the drop completes.
## Implementation Steps
### Step 1: Layout Store
Create `packages/app/src/stores/workspace-layout-store.ts`:
- `WorkspaceLayout`, `SplitNode`, `SplitPane`, `SplitGroup` types
- Zustand store with AsyncStorage persistence
- Everyday actions: `openTab`, `closeTab`, `focusTab`, `retargetTab`, `reorderTabs`
- Tree helpers: `findPaneById`, `findPaneContainingTab`, `getTreeDepth`, `collectAllTabs`
- Version 6 migration from flat tab store
### Step 2: Migrate Workspace Screen to Layout Store
Replace all `useWorkspaceTabsStore` usage in workspace-screen with the new layout store. Mobile and desktop both use the layout store — mobile just never splits. All existing behavior preserved.
### Step 3: Split Tree Transformations
Add to the layout store:
- `splitPane` with the parent-direction optimization and depth check
- `collapsePane` with unwrap cascading
- `moveTabToPane`
- `resizeSplit`
Pure tree transformation functions, tested independently.
### Step 4: Split Container Component
Create `packages/app/src/components/split-container.tsx`:
- Recursive component that renders `SplitNode`
- Groups render as flex containers with direction from `SplitGroup.direction`
- Panes render tab bar + active panel content (using the panel registry)
- Resize handles between children of a group
### Step 5: Drop Zones and Overlay
Create `packages/app/src/components/split-drop-zone.tsx`:
- Overlay that appears during tab drag
- Divides pane into 5 zones (center + 4 edges)
- Shows preview rectangle on hover
- Calls `splitPane` or `moveTabToPane` on drop
### Step 6: Cross-Pane Drag
Extend the existing dnd-kit setup:
- Tab bar items remain draggable (existing)
- Pane drop zones become droppable targets
- Tab bar of other panes become droppable targets (move to pane)
- DndContext wraps the entire split container (not individual panes)
### Step 7: Keyboard Shortcuts
Register new actions in `keyboard/actions.ts`:
- `workspace.pane.split.right`, `workspace.pane.split.down`
- `workspace.pane.focus.left/right/up/down`
- `workspace.pane.move-tab.left/right/up/down`
- `workspace.pane.close`
Add bindings in `keyboard-shortcuts.ts` and handlers in the workspace screen.
### Step 8: Pane Focus Navigation
Implement spatial navigation for `focus.left/right/up/down`:
- Walk the tree to find the focused pane's position in the layout
- Find the nearest pane in the requested direction
- Focus it
Same logic for `move-tab` shortcuts — find adjacent pane, call `moveTabToPane`.
## Constraints
- Mobile stays single-pane — same store, no special casing
- Max 4 levels of nesting
- Minimum pane size: 10% of parent
- `PaneContext` interface unchanged — no `paneId` added
- Panel registry unchanged — panels don't know about splits
- Existing tab shortcuts work on focused pane, unchanged

28
package-lock.json generated
View File

@@ -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": [
@@ -31450,15 +31450,15 @@
},
"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",
@@ -31576,11 +31576,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 +31617,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 +31661,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,12 +31677,12 @@
},
"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/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -32039,7 +32039,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",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.28",
"version": "0.1.30",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

View File

@@ -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,13 +28,13 @@
"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",

View File

@@ -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,7 +608,12 @@ export default function RootLayout() {
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
getId={({ params }) =>
`${params?.serverId}:${params?.workspaceId}`
}
/>
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}

View File

@@ -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)

View File

@@ -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"
/>

View File

@@ -19,6 +19,9 @@ describe('resolveStatusControlMode', () => {
selectedModel: '',
onSelectModel: () => undefined,
isModelLoading: false,
allProviderModels: new Map(),
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: '',
onSelectThinkingOption: () => undefined,

View File

@@ -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',

View File

@@ -1,7 +1,17 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Platform, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
import {
Bot,
Brain,
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
SlidersHorizontal,
} from 'lucide-react-native'
import { getProviderIcon } from '@/components/provider-icons'
import { CombinedModelSelector } from '@/components/combined-model-selector'
import { useQuery } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store'
import {
@@ -10,7 +20,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import type {
AgentMode,
@@ -18,6 +28,11 @@ import type {
AgentProvider,
} from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import {
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
} from '@server/server/agent/provider-manifest'
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
type StatusOption = {
@@ -26,6 +41,7 @@ type StatusOption = {
}
type ControlledAgentStatusBarProps = {
provider: string
providerOptions?: StatusOption[]
selectedProviderId?: string
onSelectProvider?: (providerId: string) => void
@@ -53,6 +69,9 @@ export interface DraftAgentStatusBarProps {
selectedModel: string
onSelectModel: (modelId: string) => void
isModelLoading: boolean
allProviderModels: Map<string, AgentModelDefinition[]>
isAllModelsLoading: boolean
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
selectedThinkingOptionId: string
onSelectThinkingOption: (thinkingOptionId: string) => void
@@ -72,7 +91,36 @@ function findOptionLabel(options: StatusOption[] | undefined, selectedId: string
return selected?.label ?? fallback
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
} as const
function getModeIconColor(
colorTier: AgentModeColorTier | undefined,
palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } }
): string {
switch (colorTier) {
case 'default':
return palette.blue[500]
case 'safe':
return palette.green[500]
case 'moderate':
return palette.amber[500]
case 'dangerous':
return palette.red[500]
case 'readonly':
return palette.purple[500]
default:
return palette.blue[500]
}
}
function ControlledStatusBar({
provider,
providerOptions,
selectedProviderId,
onSelectProvider,
@@ -113,6 +161,11 @@ function ControlledStatusBar({
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
const ProviderIcon = getProviderIcon(provider)
const hasAnyControl =
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
@@ -144,6 +197,23 @@ function ControlledStatusBar({
[thinkingOptions]
)
const renderModeOption = useCallback(
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
const visuals = getModeVisuals(provider, option.id)
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
/>
)
},
[provider, theme.colors.foreground]
)
const handleOpenChange = useCallback(
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null)
@@ -152,7 +222,7 @@ function ControlledStatusBar({
)
return (
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
<View style={styles.container}>
{isWeb ? (
<>
{providerOptions && providerOptions.length > 0 ? (
@@ -196,17 +266,20 @@ function ControlledStatusBar({
disabled={disabled || !canSelectMode}
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
style={({ pressed, hovered }) => [
styles.modeBadge,
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
accessibilityLabel={`Select agent mode (${displayMode})`}
testID="agent-mode-selector"
>
<Text style={styles.modeBadgeText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<Combobox
options={comboboxModeOptions}
@@ -217,38 +290,44 @@ function ControlledStatusBar({
onOpenChange={handleOpenChange('mode')}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
/>
</>
) : null}
<Pressable
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'model') && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ''}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
{canSelectModel ? (
<>
<Pressable
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'model') && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ''}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
</>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
<>
@@ -264,14 +343,10 @@ function ControlledStatusBar({
(disabled || !canSelectThinking) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
accessibilityLabel={`Select thinking option (${displayThinking})`}
testID="agent-thinking-selector"
>
<Brain
size={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
style={{ marginTop: 1 }}
/>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
@@ -300,7 +375,7 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<SlidersHorizontal size={theme.iconSize.lg} color={theme.colors.foreground} />
<SlidersHorizontal size={theme.iconSize.md} color={theme.colors.foreground} />
</Pressable>
<AdaptiveModalSheet
@@ -355,17 +430,57 @@ function ControlledStatusBar({
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : null}
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => (
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id)
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
return (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
leading={<Icon size={16} color={theme.colors.foreground} />}
>
{mode.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{mode.label}
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -373,36 +488,6 @@ function ControlledStatusBar({
</View>
) : null}
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
@@ -504,6 +589,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
return (
<ControlledStatusBar
provider={agent.provider}
modeOptions={
modeOptions.length > 0
? modeOptions
@@ -555,33 +641,26 @@ export function DraftAgentStatusBar({
selectedModel,
onSelectModel,
isModelLoading,
allProviderModels,
isAllModelsLoading,
onSelectProviderAndModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
disabled = false,
}: DraftAgentStatusBarProps) {
const providerOptions = useMemo<StatusOption[]>(() => {
return providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
}, [providerDefinitions])
const isWeb = Platform.OS === 'web'
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }]
}
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
return modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [modeOptions])
const modelOptions = useMemo<StatusOption[]>(() => {
const options: StatusOption[] = [{ id: '', label: 'Auto' }]
for (const model of models) {
options.push({ id: model.id, label: model.label })
}
return options
}, [models])
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
}, [thinkingOptions])
@@ -590,8 +669,45 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
if (isWeb) {
return (
<View style={styles.container}>
<CombinedModelSelector
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
isLoading={isAllModelsLoading}
disabled={disabled}
/>
<ControlledStatusBar
provider={selectedProvider}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
</View>
)
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label })
}
return (
<ControlledStatusBar
provider={selectedProvider}
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
@@ -613,18 +729,26 @@ export function DraftAgentStatusBar({
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-end',
gap: theme.spacing[1],
},
modeBadge: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius['2xl'],
},
modeIconBadge: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
borderRadius: theme.borderRadius.full,
},
modeBadgeHovered: {
backgroundColor: theme.colors.surface2,
},
@@ -640,8 +764,8 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
},
prefsButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',

View File

@@ -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}>

View File

@@ -0,0 +1,372 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Pressable, Platform } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
import { getProviderIcon } from '@/components/provider-icons'
const INLINE_MODEL_THRESHOLD = 8
type DrillDownView = { provider: string }
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: AgentProvider, modelId: string) => void
isLoading: boolean
disabled?: boolean
}
export function CombinedModelSelector({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
isLoading,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles()
const anchorRef = useRef<View>(null)
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState<'groups' | DrillDownView>('groups')
const [searchQuery, setSearchQuery] = useState('')
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open)
if (open) {
const models = allProviderModels.get(selectedProvider)
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider })
}
} else {
setView('groups')
setSearchQuery('')
}
}, [allProviderModels, selectedProvider])
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId)
setIsOpen(false)
setView('groups')
setSearchQuery('')
},
[onSelect]
)
const ProviderIcon = getProviderIcon(selectedProvider)
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider)
if (!models) return isLoading ? 'Loading...' : 'Auto'
const model = models.find((m) => m.id === selectedModel)
return model?.label ?? 'Auto'
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
return (
<>
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={() => handleOpenChange(!isOpen)}
style={({ pressed, hovered }) => [
styles.trigger,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
]}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
testID="combined-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={[]}
value=""
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
title="Select model"
>
{view === 'groups' ? (
<GroupsView
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider })
setSearchQuery('')
}}
/>
) : (
<DrillDownModelView
provider={view.provider}
providerDefinitions={providerDefinitions}
models={allProviderModels.get(view.provider) ?? []}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelect={handleSelect}
onBack={() => {
setView('groups')
setSearchQuery('')
}}
/>
)}
</Combobox>
</>
)
}
function GroupsView({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: string, modelId: string) => void
onDrillDown: (provider: string) => void
}) {
const { theme } = useUnistyles()
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? []
const isInline = models.length <= INLINE_MODEL_THRESHOLD
const ProvIcon = getProviderIcon(def.id)
return (
<View key={def.id}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}>{def.label}</Text>
</View>
{models.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
selected={model.id === selectedModel && def.id === selectedProvider}
onPress={() => onSelect(def.id, model.id)}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(def.id)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{def.label}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{models.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
)
})}
</View>
)
}
function DrillDownModelView({
provider,
providerDefinitions,
models,
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
onSelect,
onBack,
}: {
provider: string
providerDefinitions: AgentProviderDefinition[]
models: AgentModelDefinition[]
selectedProvider: string
selectedModel: string
searchQuery: string
onSearchChange: (query: string) => void
onSelect: (provider: string, modelId: string) => void
onBack: () => void
}) {
const { theme } = useUnistyles()
const ProvIcon = getProviderIcon(provider)
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models
const q = searchQuery.toLowerCase()
return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
)
}, [models, searchQuery])
return (
<View>
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
<SearchInput
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === 'web'}
/>
{filteredModels.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
description={model.description}
selected={model.id === selectedModel && provider === selectedProvider}
onPress={() => onSelect(provider, model.id)}
/>
))}
{filteredModels.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
triggerPressed: {
backgroundColor: theme.colors.surface0,
},
triggerDisabled: {
opacity: 0.5,
},
triggerText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
sectionHeadingText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
drillDownRow: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 36,
},
drillDownRowHovered: {
backgroundColor: theme.colors.surface1,
},
drillDownRowPressed: {
backgroundColor: theme.colors.surface2,
},
drillDownText: {
flex: 1,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
drillDownTrailing: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
},
drillDownCount: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButtonHovered: {
backgroundColor: theme.colors.surface1,
},
backButtonPressed: {
backgroundColor: theme.colors.surface2,
},
backButtonText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: 'center',
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}))

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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'
@@ -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,
@@ -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"
@@ -690,4 +714,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,
},
}))

View File

@@ -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',

View File

@@ -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}

View File

@@ -13,6 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { router, usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
@@ -294,7 +295,7 @@ export function ProjectPickerModal() {
]}
numberOfLines={1}
>
{path}
{shortenPath(path)}
</Text>
</View>
</Pressable>

View File

@@ -0,0 +1,12 @@
import { Bot } from 'lucide-react-native'
import { ClaudeIcon } from '@/components/icons/claude-icon'
import { CodexIcon } from '@/components/icons/codex-icon'
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
}
export function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot
}

View File

@@ -0,0 +1,190 @@
import { useCallback, useRef, useState } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export interface ResizeHandleProps {
direction: "horizontal" | "vertical";
groupId: string;
index: number;
sizes: number[];
onResizeSplit: (groupId: string, sizes: number[]) => void;
}
interface PointerState {
containerSize: number;
pointerStart: number;
leftSize: number;
rightSize: number;
}
export function ResizeHandle({
direction,
groupId,
index,
sizes,
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const pointerStateRef = useRef<PointerState | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
const [dragging, setDragging] = useState(false);
const highlighted = active || dragging;
const handlePointerDown = useCallback(
(event: any) => {
const hitAreaElement = event.currentTarget as HTMLElement | null;
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
if (!containerElement) {
return;
}
const rect = containerElement.getBoundingClientRect();
const containerSize = direction === "horizontal" ? rect.width : rect.height;
if (containerSize <= 0) {
return;
}
setDragging(true);
pointerStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
leftSize: sizes[index] ?? 0,
rightSize: sizes[index + 1] ?? 0,
};
const previousCursor = document.body.style.cursor;
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
document.body.style.cursor = nextCursor;
event.preventDefault();
function cleanup() {
pointerStateRef.current = null;
setDragging(false);
document.body.style.cursor = previousCursor;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(moveEvent: PointerEvent) {
const pointerState = pointerStateRef.current;
if (!pointerState) {
return;
}
const pointerCurrent =
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
const nextSizes = sizes.slice();
nextSizes[index] = pointerState.leftSize + deltaRatio;
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
onResizeSplit(groupId, nextSizes);
}
function handlePointerUp() {
cleanup();
}
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[direction, groupId, index, onResizeSplit, sizes]
);
return (
<View
style={[
styles.handle,
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
{ backgroundColor: theme.colors.border },
]}
>
{highlighted && (
<View
pointerEvents="none"
style={[
styles.highlight,
direction === "horizontal"
? styles.highlightHorizontal
: styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
]}
/>
)}
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={[
styles.hitArea,
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
} as any,
]}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
}, 150);
}}
onPointerLeave={() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setActive(false);
}}
/>
</View>
);
}
const styles = StyleSheet.create((_theme) => ({
handle: {
position: "relative",
flexShrink: 0,
},
handleHorizontal: {
width: 1,
alignSelf: "stretch",
},
handleVertical: {
height: 1,
width: "100%",
},
highlight: {
position: "absolute",
zIndex: 5,
},
highlightHorizontal: {
top: 0,
bottom: 0,
width: 3,
left: -1,
},
highlightVertical: {
left: 0,
right: 0,
height: 3,
top: -1,
},
hitArea: {
position: "absolute",
zIndex: 10,
},
hitAreaHorizontal: {
left: -5,
top: 0,
bottom: 0,
width: 10,
},
hitAreaVertical: {
top: -5,
left: 0,
right: 0,
height: 10,
},
}));

View File

@@ -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'
@@ -33,7 +34,6 @@ import { getIsTauri } from '@/constants/layout'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import {
buildHostNewAgentRoute,
buildHostWorkspaceRoute,
parseHostWorkspaceRouteFromPathname,
} from '@/utils/host-routes'
import {
@@ -70,6 +70,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store'
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -88,6 +89,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 +105,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
@@ -182,11 +186,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 +205,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 +223,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 +237,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 +504,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 +538,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 +612,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>
)
}
@@ -844,6 +892,8 @@ function NonGitProjectRowWithMenuContent({
workspace,
selected,
onPress,
shortcutNumber,
showShortcutBadge,
drag,
isDragging,
dragHandleProps,
@@ -854,6 +904,8 @@ function NonGitProjectRowWithMenuContent({
workspace: SidebarWorkspaceEntry
selected: boolean
onPress: () => void
shortcutNumber: number | null
showShortcutBadge: boolean
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
@@ -907,8 +959,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 +996,8 @@ function NonGitProjectRowWithMenu(props: {
workspace: SidebarWorkspaceEntry
selected: boolean
onPress: () => void
shortcutNumber: number | null
showShortcutBadge: boolean
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
@@ -953,37 +1009,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}
/>
)
}
@@ -1071,11 +1155,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 &&
@@ -1093,7 +1175,7 @@ function ProjectBlock({
return
}
onWorkspacePress?.()
router.replace(workspaceRoute as any)
navigateToWorkspace(serverId, item.workspaceId)
}}
drag={input?.drag ?? (() => {})}
isDragging={input?.isDragging ?? false}
@@ -1103,6 +1185,7 @@ function ProjectBlock({
},
[
activeWorkspaceSelection,
project.projectKind,
onWorkspacePress,
serverId,
shortcutIndexByWorkspaceKey,
@@ -1135,20 +1218,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 +1288,7 @@ export function SidebarWorkspaceList({
serverId,
collapsedProjectKeys,
onToggleProjectCollapsed,
onSetProjectCollapsed,
shortcutIndexByWorkspaceKey,
isRefreshing = false,
onRefresh,
@@ -1209,6 +1299,7 @@ export function SidebarWorkspaceList({
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const isNative = Platform.OS !== 'web'
const pathname = usePathname()
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
const isTauri = getIsTauri()
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
@@ -1219,19 +1310,8 @@ export function SidebarWorkspaceList({
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder)
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder)
const activeWorkspaceSelection = useMemo(() => {
if (!pathname) {
return null
}
const parsed = parseHostWorkspaceRouteFromPathname(pathname)
if (!parsed) {
return null
}
return {
serverId: parsed.serverId,
workspaceId: parsed.workspaceId,
}
}, [pathname])
const isWorkspaceRoute = useMemo(() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), [pathname])
const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null
const projectIconRequests = useMemo(() => {
if (!serverId) {
@@ -1361,6 +1441,8 @@ export function SidebarWorkspaceList({
if (!serverId || project.projectKind !== 'git') {
return
}
onSetProjectCollapsed(project.projectKey, false)
onWorkspacePress?.()
router.push(
buildHostNewAgentRoute(serverId, {
workingDir: project.iconWorkingDir,
@@ -1368,7 +1450,7 @@ export function SidebarWorkspaceList({
}) as any
)
},
[serverId]
[onSetProjectCollapsed, serverId, onWorkspacePress]
)
const renderProject = useCallback(
@@ -1380,7 +1462,7 @@ export function SidebarWorkspaceList({
displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
serverId={serverId}
activeWorkspaceSelection={activeWorkspaceSelection}
activeWorkspaceSelection={effectiveActiveWorkspaceSelection}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef}
@@ -1396,8 +1478,8 @@ export function SidebarWorkspaceList({
)
},
[
activeWorkspaceSelection,
collapsedProjectKeys,
effectiveActiveWorkspaceSelection,
handleCreateWorktree,
handleWorkspaceReorder,
onWorkspacePress,
@@ -1526,6 +1608,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 +1643,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 +1673,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 +1730,7 @@ const styles = StyleSheet.create((theme) => ({
shadowOffset: { width: 0, height: 4 },
},
sidebarRowSelected: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
workspaceRowContainer: {
position: 'relative',
@@ -1705,11 +1804,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,
},
}))

View File

@@ -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 (
<>

View File

@@ -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>
);
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,20 @@
const INTERACTIVE_TARGET_SELECTOR = [
"a",
"button",
"input",
"select",
"textarea",
"[role='button']",
"[role='link']",
"[contenteditable='true']",
"[data-paseo-pane-focus-exempt='true']",
].join(", ");
export function shouldFocusPaneFromEventTarget(target: EventTarget | null): boolean {
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
if (!candidate || typeof candidate.closest !== "function") {
return true;
}
return !candidate.closest(INTERACTIVE_TARGET_SELECTOR);
}

View File

@@ -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,
});
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,913 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
pointerWithin,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { Platform, View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import {
computeTabDropPreview,
type TabDropPreview,
} from "@/components/split-container-tab-drop-preview";
import {
SplitDropZone,
resolveSplitDropPosition,
type SplitDropZoneHover,
} from "@/components/split-drop-zone";
import {
deriveWorkspacePaneState,
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import {
useWorkspaceTabPresentation,
WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
interface SplitContainerProps {
layout: WorkspaceLayout;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
killTerminalPending: boolean;
killTerminalId: string | null;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
onNewTerminalTab: (input: { paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__";
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
onFocusPane: (paneId: string) => void;
onSplitPane: (input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) => void;
onSplitPaneEmpty: (input: {
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
}
interface WorkspaceTabDragData {
kind: "workspace-tab";
paneId: string;
tabId: string;
}
interface SplitPaneDropData {
kind: "split-pane-drop";
paneId: string;
}
interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout"> {
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
}
interface SplitPaneViewProps
extends Omit<
SplitNodeViewProps,
| "node"
| "workspaceKey"
| "focusedPaneId"
| "activeDragTabId"
| "showDropZones"
| "dropPreview"
| "onMoveTabToPane"
| "onResizeSplit"
> {
pane: SplitPane;
uiTabs: WorkspaceTab[];
isFocused: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
}
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab"
);
if (tabHits.length > 0) {
return tabHits;
}
const paneHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop"
);
if (paneHits.length > 0) {
return paneHits;
}
return closestCenter(args);
};
export function SplitContainer({
layout,
workspaceKey,
normalizedServerId,
normalizedWorkspaceId,
uiTabs,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId = "__new_tab_agent__",
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
}: SplitContainerProps) {
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current as WorkspaceTabDragData | undefined;
if (data?.kind !== "workspace-tab") {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
return;
}
setActiveDragTabId(data.tabId);
}, []);
const handleDragCancel = useCallback(() => {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
}, []);
const updateDropPreview = useCallback(
(
event:
| Pick<DragMoveEvent, "active" | "over">
| Pick<DragOverEvent, "active" | "over">
) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
| SplitPaneDropData
| undefined;
if (activeData?.kind !== "workspace-tab") {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const translatedRect = event.active.rect.current.translated;
const overRect = event.over?.rect;
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const targetPane = panesById.get(overData.paneId) ?? null;
if (!targetPane) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const targetTabs = getWorkspacePaneDescriptors({
pane: targetPane,
tabs: uiTabs,
});
setDropPreview(null);
setTabDropPreview(
computeTabDropPreview({
activePaneId: activeData.paneId,
activeTabId: activeData.tabId,
overPaneId: overData.paneId,
overTabId: overData.tabId,
targetTabs,
activeRect: {
left: translatedRect.left,
width: translatedRect.width,
},
overRect: {
left: overRect.left,
width: overRect.width,
},
})
);
return;
}
setTabDropPreview(null);
if (overData?.kind !== "split-pane-drop") {
setDropPreview(null);
return;
}
const centerX = translatedRect.left + translatedRect.width / 2;
const centerY = translatedRect.top + translatedRect.height / 2;
const relativeX = centerX - overRect.left;
const relativeY = centerY - overRect.top;
if (
Number.isNaN(relativeX) ||
Number.isNaN(relativeY) ||
relativeX < 0 ||
relativeX > overRect.width ||
relativeY < 0 ||
relativeY > overRect.height
) {
setDropPreview(null);
return;
}
setDropPreview({
paneId: overData.paneId,
position: resolveSplitDropPosition({
width: overRect.width,
height: overRect.height,
x: relativeX,
y: relativeY,
}),
});
},
[panesById, uiTabs]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
| SplitPaneDropData
| undefined;
setActiveDragTabId(null);
if (activeData?.kind !== "workspace-tab" || !event.over) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const sourcePane = panesById.get(activeData.paneId) ?? null;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const resolvedTabDropPreview =
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
if (sourceIndex < 0 || !resolvedTabDropPreview) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
}
setDropPreview(null);
setTabDropPreview(null);
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) {
if (dropPreview.position === "center") {
if (activeData.paneId !== overData.paneId) {
onMoveTabToPane(activeData.tabId, overData.paneId);
}
setDropPreview(null);
setTabDropPreview(null);
return;
}
onSplitPane({
tabId: activeData.tabId,
targetPaneId: overData.paneId,
position: dropPreview.position,
});
}
setDropPreview(null);
setTabDropPreview(null);
},
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
);
return (
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={layout.root}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
function DragOverlayTabChip({
tabId,
uiTabs,
normalizedServerId,
normalizedWorkspaceId,
}: {
tabId: string;
uiTabs: WorkspaceTab[];
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const tab = uiTabs.find((t) => t.tabId === tabId);
if (!tab) {
return null;
}
const descriptor: WorkspaceTabDescriptor = {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
};
return (
<DragOverlayTabChipInner
tab={descriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
);
}
function DragOverlayTabChipInner({
tab,
normalizedServerId,
normalizedWorkspaceId,
}: {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const { theme } = useUnistyles();
const presentation = useWorkspaceTabPresentation({
tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const label =
presentation.titleState === "loading" ? "Loading..." : presentation.label;
return (
<View
style={[
styles.dragOverlayChip,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
]}
>
<WorkspaceTabIcon presentation={presentation} active size={14} />
<Text
numberOfLines={1}
style={[
styles.dragOverlayLabel,
{ color: theme.colors.foreground },
]}
>
{label}
</Text>
</View>
);
}
function SplitNodeView({
node,
workspaceKey,
uiTabs,
focusedPaneId,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
}: SplitNodeViewProps) {
if (node.kind === "pane") {
return (
<SplitPaneView
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
);
}
const groupSizes =
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ??
node.group.sizes;
return (
<View
style={[
styles.group,
node.group.direction === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
]}
>
{node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}>
<View style={[styles.groupChild, { flex: groupSizes[index] ?? 1 }]}>
<SplitNodeView
node={child}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
</View>
{index < node.group.children.length - 1 ? (
<ResizeHandle
direction={node.group.direction}
groupId={node.group.id}
index={index}
sizes={groupSizes}
onResizeSplit={onResizeSplit}
/>
) : null}
</Fragment>
))}
</View>
);
}
function SplitPaneView({
pane,
uiTabs,
isFocused,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
}: SplitPaneViewProps) {
const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const paneState = useMemo(
() =>
deriveWorkspacePaneState({
pane,
tabs: uiTabs,
}),
[pane, uiTabs]
);
const paneTabs = useMemo(
() => paneState.tabs.map((tab) => tab.descriptor),
[paneState.tabs]
);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
paneTabs.map((tab) => {
const isClosingAgent =
tab.target.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.target.agentId,
});
const isClosingTerminal =
tab.target.kind === "terminal" &&
killTerminalPending &&
killTerminalId === tab.target.terminalId;
return {
tab,
isActive: tab.key === activeTabDescriptor?.key,
isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: isClosingAgent || isClosingTerminal,
};
}),
[
activeTabDescriptor?.key,
hoveredCloseTabKey,
isArchivingAgent,
killTerminalId,
killTerminalPending,
normalizedServerId,
paneTabs,
]
);
const paneContent = useMemo(
() =>
activeTabDescriptor
? buildPaneContentModel({
paneId: pane.id,
tab: activeTabDescriptor,
})
: null,
[
activeTabDescriptor,
buildPaneContentModel,
pane.id,
]
);
useEffect(() => {
if (Platform.OS !== "web") {
return;
}
const paneElement = paneRef.current as unknown as HTMLElement | null;
if (
!paneElement ||
typeof paneElement.addEventListener !== "function" ||
typeof paneElement.removeEventListener !== "function"
) {
return;
}
const handlePanePointerDown = (event: PointerEvent) => {
if (!shouldFocusPaneFromEventTarget(event.target)) {
return;
}
onFocusPane(pane.id);
};
const handlePaneFocusIn = (event: FocusEvent) => {
if (!shouldFocusPaneFromEventTarget(event.target)) {
return;
}
onFocusPane(pane.id);
};
paneElement.addEventListener("pointerdown", handlePanePointerDown, true);
paneElement.addEventListener("focusin", handlePaneFocusIn, true);
return () => {
paneElement.removeEventListener("pointerdown", handlePanePointerDown, true);
paneElement.removeEventListener("focusin", handlePaneFocusIn, true);
};
}, [onFocusPane, pane.id]);
return (
<View
ref={paneRef}
collapsable={false}
style={styles.pane}
>
<View style={styles.paneTabs}>
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
}}
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
/>
</View>
<View style={styles.paneContent}>
{paneContent ? (
<WorkspacePaneContent content={paneContent} />
) : (
renderPaneEmptyState?.() ?? null
)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
);
}
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
const next = new Map<string, SplitPane>();
function visit(current: SplitNode) {
if (current.kind === "pane") {
next.set(current.pane.id, current.pane);
return;
}
for (const child of current.group.children) {
visit(child);
}
}
visit(node);
return next;
}
function getNodeKey(node: SplitNode): string {
if (node.kind === "pane") {
return node.pane.id;
}
return node.group.id;
}
const styles = StyleSheet.create((theme) => ({
group: {
flex: 1,
minWidth: 0,
minHeight: 0,
},
groupHorizontal: {
flexDirection: "row",
},
groupVertical: {
flexDirection: "column",
},
groupChild: {
flexBasis: 0,
minWidth: 0,
minHeight: 0,
},
pane: {
position: "relative",
flex: 1,
minWidth: 0,
minHeight: 0,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
},
paneTabs: {
minWidth: 0,
},
paneContent: {
position: "relative",
flex: 1,
minWidth: 0,
minHeight: 0,
},
dragOverlayChip: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
maxWidth: 200,
},
dragOverlayLabel: {
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
}));

View File

@@ -0,0 +1,208 @@
import { useMemo } from "react";
import { useDroppable } from "@dnd-kit/core";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom";
export interface SplitDropZoneHover {
paneId: string;
position: SplitDropZonePosition;
}
export interface SplitDropZoneProps {
paneId: string;
active: boolean;
preview: SplitDropZoneHover | null;
}
const EDGE_RATIO = 0.15;
const CENTER_RATIO = 0.4;
export function buildSplitDropZoneId(paneId: string): string {
return `split-pane-drop:${paneId}`;
}
export function SplitDropZone({
paneId,
active,
preview,
}: SplitDropZoneProps) {
const { theme } = useUnistyles();
const { setNodeRef } = useDroppable({
id: buildSplitDropZoneId(paneId),
disabled: !active,
data: {
kind: "split-pane-drop",
paneId,
},
});
const previewStyles = useMemo(() => {
if (!preview || preview.paneId !== paneId) {
return null;
}
return {
overlay: [
styles.previewOverlay,
getPreviewOverlayStyle(preview.position),
{
backgroundColor: theme.colors.accent,
opacity: 0.6,
},
],
frame: [
styles.previewFrame,
getPreviewFrameStyle(preview.position),
{
borderColor: theme.colors.accent,
},
],
};
}, [paneId, preview, theme.colors.accent]);
if (!active) {
return null;
}
return (
<View
ref={setNodeRef as any}
style={styles.overlay}
pointerEvents="none"
>
{previewStyles ? (
<>
<View pointerEvents="none" style={previewStyles.overlay} />
<View pointerEvents="none" style={previewStyles.frame} />
</>
) : null}
</View>
);
}
export function resolveSplitDropPosition(input: {
width: number;
height: number;
x: number;
y: number;
}): SplitDropZonePosition {
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
const insideCenterX =
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
const insideCenterY =
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
if (insideCenterX && insideCenterY) {
return "center";
}
const edgeThresholdX = input.width * EDGE_RATIO;
const edgeThresholdY = input.height * EDGE_RATIO;
if (input.x <= edgeThresholdX) {
return "left";
}
if (input.x >= input.width - edgeThresholdX) {
return "right";
}
if (input.y <= edgeThresholdY) {
return "top";
}
if (input.y >= input.height - edgeThresholdY) {
return "bottom";
}
const distances = [
{ position: "left", distance: input.x },
{ position: "right", distance: input.width - input.x },
{ position: "top", distance: input.y },
{ position: "bottom", distance: input.height - input.y },
] satisfies Array<{ position: Exclude<SplitDropZonePosition, "center">; distance: number }>;
distances.sort((left, right) => left.distance - right.distance);
return distances[0]?.position ?? "center";
}
function getPreviewOverlayStyle(position: SplitDropZonePosition) {
if (position === "left") {
return styles.previewLeft;
}
if (position === "right") {
return styles.previewRight;
}
if (position === "top") {
return styles.previewTop;
}
if (position === "bottom") {
return styles.previewBottom;
}
return styles.previewCenterOverlay;
}
function getPreviewFrameStyle(position: SplitDropZonePosition) {
if (position === "left") {
return styles.previewLeft;
}
if (position === "right") {
return styles.previewRight;
}
if (position === "top") {
return styles.previewTop;
}
if (position === "bottom") {
return styles.previewBottom;
}
return styles.previewCenterFrame;
}
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 40,
},
previewOverlay: {
position: "absolute",
borderRadius: theme.borderRadius.md,
},
previewFrame: {
position: "absolute",
borderRadius: theme.borderRadius.md,
borderWidth: 2,
},
previewLeft: {
left: 0,
top: 0,
bottom: 0,
width: "50%",
},
previewRight: {
right: 0,
top: 0,
bottom: 0,
width: "50%",
},
previewTop: {
left: 0,
top: 0,
right: 0,
height: "50%",
},
previewBottom: {
left: 0,
right: 0,
bottom: 0,
height: "50%",
},
previewCenterOverlay: {
left: theme.spacing[2],
top: theme.spacing[2],
right: theme.spacing[2],
bottom: theme.spacing[2],
},
previewCenterFrame: {
left: theme.spacing[2],
top: theme.spacing[2],
right: theme.spacing[2],
bottom: theme.spacing[2],
},
}));

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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,
},
};
});

View File

@@ -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>

View File

@@ -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}

View File

@@ -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)

View File

@@ -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,
},
}));

View File

@@ -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,
]

View File

@@ -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();
@@ -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,17 +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]);
}, [agents, open, query]);
const newAgentRoute = useMemo<Href>(() => {
const serverIdFromPath = activeServerId;
@@ -149,6 +159,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) => ({
@@ -159,9 +172,12 @@ export function useCommandCenter() {
route: action.buildRoute({ newAgentRoute, settingsRoute }),
shortcutKeys: action.shortcutKeys,
}));
}, [newAgentRoute, query, settingsRoute]);
}, [newAgentRoute, 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 +192,7 @@ export function useCommandCenter() {
});
}
return next;
}, [actionItems, agentResults]);
}, [actionItems, agentResults, open]);
const handleClose = useCallback(() => {
setOpen(false);

View File

@@ -1,14 +1,14 @@
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 { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildHostWorkspaceRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import {
type MessageInputKeyboardActionKind,
type KeyboardShortcutPayload,
@@ -32,7 +32,6 @@ export function useKeyboardShortcuts({
selectedAgentId?: string;
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
@@ -57,11 +56,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 +72,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,7 +87,7 @@ export function useKeyboardShortcuts({
if (!target) {
return false;
}
router.replace(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
navigateToWorkspace(target.serverId, target.workspaceId);
return true;
};
@@ -134,22 +129,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 +138,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;
@@ -326,7 +328,6 @@ export function useKeyboardShortcuts({
isMobile,
pathname,
resetModifiers,
router,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,

View File

@@ -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,
}
}

View File

@@ -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);
},
[]
),
};
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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"],
},
},
{

View File

@@ -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",

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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 },
};
}

View File

@@ -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 });

View File

@@ -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,
useWorkspaceTabPresentation,
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;
};
export function WorkspaceDesktopTabsRow({
tabs,
activeTabKey,
agentsById,
normalizedServerId,
hoveredCloseTabKey,
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,
normalizedServerId,
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,213 @@ 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 presentation = useWorkspaceTabPresentation({
tab: item.tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const tooltipLabel =
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
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 (
<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>
);
}
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 +664,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 +733,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",

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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",
});
});
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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))

View File

@@ -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,

View File

@@ -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),
};
}

View File

@@ -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",
});
});
});

View File

@@ -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,
};
}

View File

@@ -1,51 +1,59 @@
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;
};
}
export function deriveWorkspaceTabPresentation(input: {
export function useWorkspaceTabPresentation(input: {
tab: WorkspaceTabDescriptor;
agent?: Agent | null;
serverId: string;
workspaceId: string;
}): 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,
};
ensurePanelsRegistered();
const registration = getPanelRegistration(input.tab.kind);
invariant(registration, `No panel registration for kind: ${input.tab.kind}`);
const descriptor = registration.useDescriptor(input.tab.target, {
serverId: input.serverId,
workspaceId: input.workspaceId,
});
return useMemo(
() => ({
key: input.tab.key,
kind: input.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,
input.tab.key,
input.tab.kind,
]
);
}
type WorkspaceTabIconProps = {
@@ -74,49 +82,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 = {

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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 });
},
})
);

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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());
});
});

View File

@@ -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,
};
},
}
)
);

View File

@@ -1,77 +0,0 @@
type TerminalDebugGlobal = {
__PASEO_TERMINAL_DEBUG?: boolean;
};
type TerminalDebugLogInput = {
scope: string;
event: string;
details?: Record<string, unknown>;
};
function resolveGlobalDebugFlag(): boolean | null {
if (typeof globalThis === "undefined") {
return null;
}
const value = (globalThis as TerminalDebugGlobal).__PASEO_TERMINAL_DEBUG;
if (typeof value === "boolean") {
return value;
}
return null;
}
export function isTerminalDebugEnabled(): boolean {
const globalFlag = resolveGlobalDebugFlag();
if (globalFlag !== null) {
return globalFlag;
}
return process.env.NODE_ENV === "development";
}
export function terminalDebugLog(input: TerminalDebugLogInput): void {
if (!isTerminalDebugEnabled()) {
return;
}
const payload = input.details
? { ...input.details, ts: Date.now() }
: { ts: Date.now() };
console.log(`[terminal][${input.scope}] ${input.event}`, payload);
}
function escapeControlBytes(input: { text: string }): string {
let output = "";
for (const char of input.text) {
const code = char.charCodeAt(0);
if (code === 10) {
output += "\\n";
continue;
}
if (code === 13) {
output += "\\r";
continue;
}
if (code === 9) {
output += "\\t";
continue;
}
if (code < 32 || code === 127) {
output += `\\x${code.toString(16).padStart(2, "0")}`;
continue;
}
output += char;
}
return output;
}
export function summarizeTerminalText(input: {
text: string;
maxChars?: number;
}): string {
const maxChars = input.maxChars ?? 80;
const escaped = escapeControlBytes({ text: input.text });
if (escaped.length <= maxChars) {
return escaped;
}
return `${escaped.slice(0, maxChars)}`;
}

View File

@@ -10,7 +10,6 @@ import {
normalizeTerminalTransportKey,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalEmulatorRuntimeMountInput = {
root: HTMLDivElement;
@@ -87,6 +86,13 @@ const DEFAULT_TERMINAL_FONT_FAMILY = [
"monospace",
].join(", ");
function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
return {
...theme,
overviewRulerBorder: theme.background ?? "transparent",
};
}
export class TerminalEmulatorRuntime {
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
private pendingModifiers: PendingTerminalModifiers = {
@@ -126,13 +132,6 @@ export class TerminalEmulatorRuntime {
}
mount(input: TerminalEmulatorRuntimeMountInput): void {
terminalDebugLog({
scope: "emulator-runtime",
event: "mount:start",
details: {
initialOutputLength: input.initialOutputText.length,
},
});
this.unmount();
input.host.innerHTML = "";
@@ -146,8 +145,11 @@ export class TerminalEmulatorRuntime {
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontSize: 13,
lineHeight: 1.0,
overviewRuler: {
width: 8,
},
scrollback: 10_000,
theme: input.theme,
theme: withOverviewRulerBorderHidden(input.theme),
});
const fitAddon = new FitAddon();
const unicode11Addon = new Unicode11Addon();
@@ -219,15 +221,6 @@ export class TerminalEmulatorRuntime {
}
this.lastSize = { rows: nextRows, cols: nextCols };
terminalDebugLog({
scope: "emulator-runtime",
event: "resize:emit",
details: {
rows: nextRows,
cols: nextCols,
force,
},
});
this.callbacks.onResize?.({
rows: nextRows,
cols: nextCols,
@@ -239,24 +232,8 @@ export class TerminalEmulatorRuntime {
const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) {
terminalDebugLog({
scope: "emulator-runtime",
event: "input:suppressed",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
},
});
return;
}
terminalDebugLog({
scope: "emulator-runtime",
event: "input:onData",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
},
});
this.callbacks.onInput?.(data);
});
@@ -292,17 +269,6 @@ export class TerminalEmulatorRuntime {
key: normalizeTerminalTransportKey(normalizedKey),
...modifiers,
});
terminalDebugLog({
scope: "emulator-runtime",
event: "key:intercepted",
details: {
key: normalizedKey,
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
meta: modifiers.meta,
},
});
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
this.callbacks.onPendingModifiersConsumed?.();
@@ -368,17 +334,6 @@ export class TerminalEmulatorRuntime {
if (input.initialOutputText.length > 0) {
this.write({ text: input.initialOutputText, suppressInput: true });
terminalDebugLog({
scope: "emulator-runtime",
event: "output:initial-write",
details: {
length: input.initialOutputText.length,
preview: summarizeTerminalText({
text: input.initialOutputText,
maxChars: 96,
}),
},
});
}
this.processOutputQueue();
@@ -466,15 +421,6 @@ export class TerminalEmulatorRuntime {
suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
scope: "emulator-runtime",
event: "output:enqueue",
details: {
chunkLength: input.text.length,
queueLength: this.outputOperations.length,
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
},
});
this.processOutputQueue();
}
@@ -485,10 +431,6 @@ export class TerminalEmulatorRuntime {
suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
scope: "emulator-runtime",
event: "output:clear",
});
this.processOutputQueue();
}
@@ -503,11 +445,7 @@ export class TerminalEmulatorRuntime {
}
try {
terminal.options.theme = input.theme;
terminalDebugLog({
scope: "emulator-runtime",
event: "theme:update",
});
terminal.options.theme = withOverviewRulerBorderHidden(input.theme);
} catch {
// ignore
return;
@@ -525,13 +463,6 @@ export class TerminalEmulatorRuntime {
}
unmount(): void {
terminalDebugLog({
scope: "emulator-runtime",
event: "mount:unmount",
details: {
pendingWriteOperations: this.outputOperations.length,
},
});
this.clearInFlightOutputTimeout();
const inFlightOperation = this.inFlightOutputOperation;
this.inFlightOutputOperation = null;
@@ -593,14 +524,6 @@ export class TerminalEmulatorRuntime {
}
const text = operation.text;
terminalDebugLog({
scope: "emulator-runtime",
event: "output:flush",
details: {
length: text.length,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.inFlightOutputOperationTimeout = setTimeout(() => {
finalizeOperation(operation);
}, OUTPUT_OPERATION_TIMEOUT_MS);

View File

@@ -1,5 +1,3 @@
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputDeliveryChunk = {
sequence: number;
text: string;
@@ -37,13 +35,6 @@ export class TerminalOutputDeliveryQueue {
if (chunk.text.length === 0) {
this.pendingChunks.length = 0;
this.pendingChunks.push(chunk);
terminalDebugLog({
scope: "output-delivery-queue",
event: "enqueue:clear",
details: {
sequence: chunk.sequence,
},
});
this.tryDeliver();
return;
}
@@ -59,39 +50,14 @@ export class TerminalOutputDeliveryQueue {
} else {
this.pendingChunks.push(chunk);
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "enqueue:text",
details: {
sequence: chunk.sequence,
pendingCount: this.pendingChunks.length,
textLength: chunk.text.length,
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
},
});
this.tryDeliver();
}
consume(input: { sequence: number }): void {
if (this.inFlightChunk?.sequence !== input.sequence) {
terminalDebugLog({
scope: "output-delivery-queue",
event: "consume:ignored",
details: {
sequence: input.sequence,
inFlightSequence: this.inFlightChunk?.sequence ?? null,
},
});
return;
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "consume",
details: {
sequence: input.sequence,
},
});
this.clearInFlightTimeout();
this.inFlightChunk = null;
this.tryDeliver();
@@ -114,14 +80,6 @@ export class TerminalOutputDeliveryQueue {
}
this.inFlightChunk = nextChunk;
terminalDebugLog({
scope: "output-delivery-queue",
event: "deliver:start",
details: {
sequence: nextChunk.sequence,
pendingCount: this.pendingChunks.length,
},
});
this.deliverInFlightChunk();
}
@@ -136,15 +94,6 @@ export class TerminalOutputDeliveryQueue {
if (!this.inFlightChunk) {
return;
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "deliver:timeout-retry",
details: {
sequence: this.inFlightChunk.sequence,
pendingCount: this.pendingChunks.length,
timeoutMs: this.deliveryTimeoutMs,
},
});
this.deliverInFlightChunk();
}, this.deliveryTimeoutMs);

View File

@@ -4,7 +4,6 @@ import {
readTerminalOutputBuffer,
type TerminalOutputBuffer,
} from "@/utils/terminal-output-buffer";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputChunk = {
sequence: number;
@@ -54,14 +53,6 @@ export class TerminalOutputPump {
return;
}
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:set",
details: {
previousTerminalId: this.selectedTerminalId,
nextTerminalId: input.terminalId,
},
});
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
@@ -102,17 +93,6 @@ export class TerminalOutputPump {
}
this.selectedChunkAccumulator += input.text;
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:accumulate",
details: {
terminalId: input.terminalId,
appendedLength: input.text.length,
accumulatorLength: this.selectedChunkAccumulator.length,
replay: input.replay,
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
},
});
this.scheduleSelectedChunkFlush();
}
@@ -173,16 +153,6 @@ export class TerminalOutputPump {
const replay = this.selectedChunkAccumulatorReplay ?? false;
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:flush",
details: {
terminalId: this.selectedTerminalId,
textLength: text.length,
replay,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.emitSelectedChunk({ text, replay });
}

View File

@@ -6,7 +6,6 @@ import {
TerminalOutputDeliveryQueue,
type TerminalOutputDeliveryChunk,
} from "./terminal-output-delivery-queue";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
type TerminalOutputState = {
selectedTerminalId: string | null;
@@ -75,18 +74,6 @@ export class TerminalOutputSession {
chunkSequence: chunk.sequence,
chunkReplay: chunk.replay,
};
terminalDebugLog({
scope: "output-session",
event: "deliver",
details: {
selectedTerminalId: this.state.selectedTerminalId,
sequence: chunk.sequence,
replay: chunk.replay,
chunkLength: chunk.text.length,
snapshotLength: snapshotText.length,
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
},
});
this.emit();
},
});
@@ -121,14 +108,6 @@ export class TerminalOutputSession {
chunkSequence: 0,
chunkReplay: false,
};
terminalDebugLog({
scope: "output-session",
event: "selected-terminal:set",
details: {
selectedTerminalId: input.terminalId,
snapshotLength: snapshotText.length,
},
});
this.emit();
}
@@ -154,13 +133,6 @@ export class TerminalOutputSession {
chunkSequence: 0,
chunkReplay: false,
};
terminalDebugLog({
scope: "output-session",
event: "selected-terminal:clear",
details: {
terminalId: input.terminalId,
},
});
this.emit();
}
@@ -208,14 +180,6 @@ export function releaseTerminalOutputSession(input: { scopeKey: string }): void
if (current <= 1) {
sessionRefCountByScopeKey.delete(input.scopeKey);
sessionsByScopeKey.delete(input.scopeKey);
terminalDebugLog({
scope: "output-session",
event: "scope:release",
details: {
scopeKey: input.scopeKey,
released: true,
},
});
return;
}

View File

@@ -7,7 +7,6 @@ import {
waitForDuration,
withPromiseTimeout,
} from "@/utils/terminal-attach";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalStreamControllerAttachPayload = {
streamId: number | null;
@@ -114,14 +113,6 @@ export class TerminalStreamController {
if (this.isDisposed) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "terminal:set",
details: {
previousTerminalId: this.selectedTerminalId,
nextTerminalId: input.terminalId,
},
});
const nextTerminalId = input.terminalId;
const previousTerminalId = this.selectedTerminalId;
@@ -166,14 +157,6 @@ export class TerminalStreamController {
if (this.isDisposed) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:exit",
details: {
terminalId: input.terminalId,
streamId: input.streamId,
},
});
if (this.selectedTerminalId !== input.terminalId) {
return;
@@ -256,16 +239,6 @@ export class TerminalStreamController {
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "attach:attempt",
details: {
terminalId: input.terminalId,
generation: input.generation,
attempt,
maxAttachAttempts,
},
});
try {
const preferredSize = this.options.getPreferredSize();
@@ -299,15 +272,6 @@ export class TerminalStreamController {
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
terminalDebugLog({
scope: "stream-controller",
event: "attach:response-error",
details: {
terminalId: input.terminalId,
attempt,
error: lastErrorMessage,
},
});
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
@@ -385,19 +349,6 @@ export class TerminalStreamController {
} else {
unsubscribe();
}
terminalDebugLog({
scope: "stream-controller",
event: "attach:success",
details: {
terminalId: input.terminalId,
streamId,
replayedFrom: attachPayload.replayedFrom ?? null,
requestedResumeOffset: resumeOffset ?? null,
currentOffset: attachPayload.currentOffset,
reset: attachPayload.reset,
bootstrapReset: shouldResetForReplayBootstrap,
},
});
this.updateStatus({
terminalId: input.terminalId,
streamId,
@@ -408,15 +359,6 @@ export class TerminalStreamController {
} catch (error) {
lastErrorMessage =
error instanceof Error ? error.message : "Unable to attach terminal stream";
terminalDebugLog({
scope: "stream-controller",
event: "attach:exception",
details: {
terminalId: input.terminalId,
attempt,
error: lastErrorMessage,
},
});
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
@@ -510,21 +452,6 @@ export class TerminalStreamController {
if (text.length === 0) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:chunk",
details: {
terminalId: input.terminalId,
streamId: input.streamId,
offset: chunkOffset,
endOffset: chunkEndOffset,
replay: Boolean(input.chunk.replay),
byteLength: input.chunk.data.byteLength,
textLength: text.length,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.options.onChunk({
terminalId: input.terminalId,
text,
@@ -577,15 +504,6 @@ export class TerminalStreamController {
if (!activeStream) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:detach",
details: {
terminalId: activeStream.terminalId,
streamId: activeStream.streamId,
shouldDetach: input.shouldDetach,
},
});
this.activeStream = null;
try {
@@ -633,16 +551,6 @@ export class TerminalStreamController {
private updateStatus(status: TerminalStreamControllerStatus): void {
this.status = status;
terminalDebugLog({
scope: "stream-controller",
event: "status:update",
details: {
terminalId: status.terminalId,
streamId: status.streamId,
isAttaching: status.isAttaching,
error: status.error,
},
});
this.options.onStatusChange?.(status);
}

View File

@@ -39,6 +39,10 @@ describe("confirmDialog", () => {
it("uses Tauri dialog.ask on web when available", async () => {
const askMock = vi.fn(async () => true);
const blurMock = vi.fn();
(globalThis as { document?: unknown }).document = {
activeElement: { blur: blurMock },
} as unknown as Document;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
dialog: { ask: askMock },
};
@@ -54,6 +58,7 @@ describe("confirmDialog", () => {
expect(confirmed).toBe(true);
expect(alertMock).not.toHaveBeenCalled();
expect(blurMock).toHaveBeenCalledTimes(1);
expect(askMock).toHaveBeenCalledWith("This will restart the daemon.", {
title: "Restart host",
okLabel: "Restart",
@@ -64,6 +69,10 @@ describe("confirmDialog", () => {
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
const browserConfirm = vi.fn(() => true);
const blurMock = vi.fn();
(globalThis as { document?: unknown }).document = {
activeElement: { blur: blurMock },
} as unknown as Document;
(globalThis as { confirm?: unknown }).confirm = browserConfirm;
const { confirmDialog } = await loadModuleForPlatform("web");
@@ -73,6 +82,7 @@ describe("confirmDialog", () => {
});
expect(confirmed).toBe(true);
expect(blurMock).toHaveBeenCalledTimes(1);
expect(browserConfirm).toHaveBeenCalledWith(
"Restart host\n\nThis will restart the daemon."
);

View File

@@ -66,12 +66,21 @@ function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions
};
}
function blurActiveWebElement(): void {
if (Platform.OS !== "web") {
return;
}
const activeElement = (globalThis as { document?: Document }).document?.activeElement;
(activeElement as HTMLElement | null)?.blur?.();
}
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
const tauriApi = getTauriApi();
if (!tauriApi) {
return null;
}
blurActiveWebElement();
const options = buildTauriAskOptions(input);
const tauriAsk = tauriApi.dialog?.ask;
@@ -105,6 +114,7 @@ function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
throw new Error("[ConfirmDialog] No web confirmation backend is available.");
}
blurActiveWebElement();
const promptMessage = `${input.title}\n\n${input.message}`;
return browserConfirm(promptMessage);
}
@@ -121,3 +131,8 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
return showWebConfirmDialog(input);
}
export const __private__ = {
blurActiveWebElement,
buildTauriAskOptions,
};

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildSidebarProjectRowModel } from './sidebar-project-row-model'
import {
buildSidebarProjectRowModel,
isSidebarProjectFlattened,
} from './sidebar-project-row-model'
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
@@ -51,11 +54,11 @@ describe('buildSidebarProjectRowModel', () => {
})
expect(result).toEqual({
interaction: 'navigate',
chevron: 'disclosure',
trailingAction: 'none',
flattenedWorkspace,
kind: 'workspace_link',
workspace: flattenedWorkspace,
selected: false,
chevron: null,
trailingAction: 'none',
})
})
@@ -78,10 +81,36 @@ describe('buildSidebarProjectRowModel', () => {
},
})
expect(result.selected).toBe(true)
expect(result).toMatchObject({
kind: 'workspace_link',
selected: true,
})
})
it('keeps git projects as expandable sections with a new worktree action', () => {
it('flattens git projects with a single workspace and keeps the new worktree action', () => {
const flattenedWorkspace = workspace({
workspaceId: '/repo/main',
workspaceKind: 'local_checkout',
})
const result = buildSidebarProjectRowModel({
project: project({
projectKind: 'git',
workspaces: [flattenedWorkspace],
}),
collapsed: true,
})
expect(result).toEqual({
kind: 'workspace_link',
workspace: flattenedWorkspace,
selected: false,
chevron: null,
trailingAction: 'new_worktree',
})
})
it('keeps multi-workspace git projects as expandable sections with a new worktree action', () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: 'git',
@@ -94,11 +123,28 @@ describe('buildSidebarProjectRowModel', () => {
})
expect(result).toEqual({
interaction: 'toggle',
kind: 'project_section',
chevron: 'expand',
trailingAction: 'new_worktree',
flattenedWorkspace: null,
selected: false,
})
})
})
describe('isSidebarProjectFlattened', () => {
it('returns true for single-workspace projects regardless of kind', () => {
expect(isSidebarProjectFlattened(project({ projectKind: 'git', workspaces: [workspace()] }))).toBe(true)
expect(
isSidebarProjectFlattened(project({ projectKind: 'non_git', workspaces: [workspace()] }))
).toBe(true)
})
it('returns false for multi-workspace projects', () => {
expect(
isSidebarProjectFlattened(
project({
workspaces: [workspace({ workspaceId: '/repo/main' }), workspace({ workspaceId: '/repo/feat' })],
})
)
).toBe(false)
})
})

View File

@@ -3,12 +3,26 @@ import type {
SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-workspaces-list'
export interface SidebarProjectRowModel {
interaction: 'toggle' | 'navigate'
chevron: 'expand' | 'collapse' | 'disclosure'
trailingAction: 'new_worktree' | 'none'
flattenedWorkspace: SidebarWorkspaceEntry | null
export interface SidebarProjectWorkspaceLinkRowModel {
kind: 'workspace_link'
workspace: SidebarWorkspaceEntry
selected: boolean
chevron: null
trailingAction: 'new_worktree' | 'none'
}
export interface SidebarProjectSectionRowModel {
kind: 'project_section'
chevron: 'expand' | 'collapse'
trailingAction: 'new_worktree' | 'none'
}
export type SidebarProjectRowModel =
| SidebarProjectWorkspaceLinkRowModel
| SidebarProjectSectionRowModel
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
return project.workspaces.length === 1
}
export function buildSidebarProjectRowModel(input: {
@@ -17,10 +31,9 @@ export function buildSidebarProjectRowModel(input: {
serverId?: string | null
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null
}): SidebarProjectRowModel {
const flattenedWorkspace =
input.project.projectKind === 'non_git' && input.project.workspaces.length === 1
? input.project.workspaces[0] ?? null
: null
const flattenedWorkspace = isSidebarProjectFlattened(input.project)
? (input.project.workspaces[0] ?? null)
: null
const selected =
flattenedWorkspace !== null &&
Boolean(input.serverId) &&
@@ -29,19 +42,17 @@ export function buildSidebarProjectRowModel(input: {
if (flattenedWorkspace) {
return {
interaction: 'navigate',
chevron: 'disclosure',
trailingAction: 'none',
flattenedWorkspace,
kind: 'workspace_link',
workspace: flattenedWorkspace,
selected,
chevron: null,
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
}
}
return {
interaction: 'toggle',
kind: 'project_section',
chevron: input.collapsed ? 'expand' : 'collapse',
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
flattenedWorkspace: null,
selected: false,
}
}

View File

@@ -34,7 +34,7 @@ describe("buildSidebarShortcutModel", () => {
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
project("p2", [workspace("s1", "/repo2/main")]),
project("p2", [workspace("s1", "/repo2/main"), workspace("s1", "/repo2/feat-a")]),
];
const model = buildSidebarShortcutModel({
@@ -70,4 +70,16 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
});
it("ignores collapsed state for flattened single-workspace projects", () => {
const projects = [project("p1", [workspace("s1", "/repo/main")])];
const model = buildSidebarShortcutModel({
projects,
collapsedProjectKeys: new Set<string>(["p1"]),
});
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
});
});

View File

@@ -2,6 +2,7 @@ import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-workspaces-list'
import { isSidebarProjectFlattened } from './sidebar-project-row-model'
export interface SidebarShortcutWorkspaceTarget {
serverId: string
@@ -34,7 +35,7 @@ export function buildSidebarShortcutModel(input: {
const shortcutIndexByWorkspaceKey = new Map<string, number>()
for (const project of input.projects) {
if (input.collapsedProjectKeys.has(project.projectKey)) {
if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
continue
}

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { SplitNode } from "@/stores/workspace-layout-store";
import { findAdjacentPane } from "./split-navigation";
function createPaneNode(id: string): SplitNode {
return {
kind: "pane",
pane: {
id,
tabIds: [],
focusedTabId: null,
},
};
}
function createGroupNode(input: {
direction: "horizontal" | "vertical";
sizes: number[];
children: SplitNode[];
}): SplitNode {
return {
kind: "group",
group: {
id: `${input.direction}-group`,
direction: input.direction,
sizes: input.sizes,
children: input.children,
},
};
}
describe("findAdjacentPane", () => {
it("finds direct horizontal and vertical neighbors in nested layouts", () => {
const root = createGroupNode({
direction: "horizontal",
sizes: [0.25, 0.5, 0.25],
children: [
createPaneNode("left"),
createGroupNode({
direction: "vertical",
sizes: [0.5, 0.5],
children: [createPaneNode("top-middle"), createPaneNode("bottom-middle")],
}),
createPaneNode("right"),
],
});
expect(findAdjacentPane(root, "top-middle", "left")).toBe("left");
expect(findAdjacentPane(root, "top-middle", "right")).toBe("right");
expect(findAdjacentPane(root, "top-middle", "down")).toBe("bottom-middle");
expect(findAdjacentPane(root, "bottom-middle", "up")).toBe("top-middle");
});
it("returns null when there is no pane in the requested direction", () => {
const root = createGroupNode({
direction: "horizontal",
sizes: [0.5, 0.5],
children: [createPaneNode("left"), createPaneNode("right")],
});
expect(findAdjacentPane(root, "left", "left")).toBeNull();
expect(findAdjacentPane(root, "right", "right")).toBeNull();
expect(findAdjacentPane(root, "left", "up")).toBeNull();
});
it("prefers the closest overlapping pane when multiple candidates exist", () => {
const root = createGroupNode({
direction: "vertical",
sizes: [0.5, 0.5],
children: [
createPaneNode("top"),
createGroupNode({
direction: "horizontal",
sizes: [0.5, 0.5],
children: [createPaneNode("bottom-left"), createPaneNode("bottom-right")],
}),
],
});
expect(findAdjacentPane(root, "top", "down")).toBe("bottom-left");
expect(findAdjacentPane(root, "bottom-right", "up")).toBe("top");
});
});

View File

@@ -0,0 +1,251 @@
import type { SplitNode } from "@/stores/workspace-layout-store";
const ROOT_MIN = 0;
const ROOT_MAX = 1;
const FLOAT_TOLERANCE = 0.000001;
export interface PaneBounds {
paneId: string;
left: number;
top: number;
right: number;
bottom: number;
centerX: number;
centerY: number;
}
interface PaneCandidate {
paneId: string;
primaryDistance: number;
secondaryDistance: number;
centerDistance: number;
overlap: number;
}
export function findAdjacentPane(
root: SplitNode,
focusedPaneId: string,
direction: "left" | "right" | "up" | "down"
): string | null {
const panes = collectPaneBounds(root, {
left: ROOT_MIN,
top: ROOT_MIN,
right: ROOT_MAX,
bottom: ROOT_MAX,
});
const focusedPane = panes.find((pane) => pane.paneId === focusedPaneId) ?? null;
if (!focusedPane) {
return null;
}
const candidates = panes
.filter((pane) => pane.paneId !== focusedPaneId)
.map((pane) => buildCandidate({ pane, focusedPane, direction }))
.filter((candidate): candidate is PaneCandidate => candidate !== null)
.sort(compareCandidates);
return candidates[0]?.paneId ?? null;
}
function compareCandidates(left: PaneCandidate, right: PaneCandidate): number {
if (left.primaryDistance !== right.primaryDistance) {
return left.primaryDistance - right.primaryDistance;
}
if (left.secondaryDistance !== right.secondaryDistance) {
return left.secondaryDistance - right.secondaryDistance;
}
if (left.overlap !== right.overlap) {
return right.overlap - left.overlap;
}
if (left.centerDistance !== right.centerDistance) {
return left.centerDistance - right.centerDistance;
}
return left.paneId.localeCompare(right.paneId);
}
function buildCandidate(input: {
pane: PaneBounds;
focusedPane: PaneBounds;
direction: "left" | "right" | "up" | "down";
}): PaneCandidate | null {
const { pane, focusedPane, direction } = input;
if (direction === "left") {
const primaryDistance = focusedPane.left - pane.right;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
}),
centerDistance: Math.abs(pane.centerY - focusedPane.centerY),
overlap,
};
}
if (direction === "right") {
const primaryDistance = pane.left - focusedPane.right;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
}),
centerDistance: Math.abs(pane.centerY - focusedPane.centerY),
overlap,
};
}
if (direction === "up") {
const primaryDistance = focusedPane.top - pane.bottom;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
}),
centerDistance: Math.abs(pane.centerX - focusedPane.centerX),
overlap,
};
}
const primaryDistance = pane.top - focusedPane.bottom;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
}),
centerDistance: Math.abs(pane.centerX - focusedPane.centerX),
overlap,
};
}
function collectPaneBounds(
node: SplitNode,
bounds: { left: number; top: number; right: number; bottom: number }
): PaneBounds[] {
if (node.kind === "pane") {
return [
{
paneId: node.pane.id,
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
centerX: (bounds.left + bounds.right) / 2,
centerY: (bounds.top + bounds.bottom) / 2,
},
];
}
const panes: PaneBounds[] = [];
const totalWidth = bounds.right - bounds.left;
const totalHeight = bounds.bottom - bounds.top;
let offset = 0;
for (let index = 0; index < node.group.children.length; index += 1) {
const child = node.group.children[index];
const size = node.group.sizes[index] ?? 0;
if (node.group.direction === "horizontal") {
const childLeft = bounds.left + totalWidth * offset;
offset += size;
const childRight = bounds.left + totalWidth * offset;
panes.push(
...collectPaneBounds(child, {
left: childLeft,
top: bounds.top,
right: childRight,
bottom: bounds.bottom,
})
);
continue;
}
const childTop = bounds.top + totalHeight * offset;
offset += size;
const childBottom = bounds.top + totalHeight * offset;
panes.push(
...collectPaneBounds(child, {
left: bounds.left,
top: childTop,
right: bounds.right,
bottom: childBottom,
})
);
}
return panes;
}
function getGapLength(input: {
startA: number;
endA: number;
startB: number;
endB: number;
}): number {
const { startA, endA, startB, endB } = input;
if (endA < startB) {
return startB - endA;
}
if (endB < startA) {
return startA - endB;
}
return 0;
}
function getOverlapLength(input: {
startA: number;
endA: number;
startB: number;
endB: number;
}): number {
const { startA, endA, startB, endB } = input;
return Math.max(0, Math.min(endA, endB) - Math.max(startA, startB));
}

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const tauriMock = vi.hoisted(() => {
let listenerCleanup: (() => void) | null = null;
let disconnectCount = 0;
const connect = vi.fn(async () => {
let listenerActive = false;
@@ -21,6 +22,7 @@ const tauriMock = vi.hoisted(() => {
}),
send: vi.fn(async () => undefined),
disconnect: vi.fn(async () => {
disconnectCount += 1;
listenerCleanup?.();
}),
};
@@ -29,6 +31,11 @@ const tauriMock = vi.hoisted(() => {
return {
connect,
getListenerCleanup: () => listenerCleanup,
getDisconnectCount: () => disconnectCount,
resetState: () => {
listenerCleanup = null;
disconnectCount = 0;
},
};
});
@@ -43,6 +50,7 @@ vi.mock("./tauri", () => ({
describe("tauri-daemon-transport", () => {
beforeEach(() => {
tauriMock.connect.mockClear();
tauriMock.resetState();
});
it("does not unregister the websocket listener twice during close", async () => {
@@ -54,6 +62,82 @@ describe("tauri-daemon-transport", () => {
await Promise.resolve();
expect(() => transport.close()).not.toThrow();
await Promise.resolve();
expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1);
});
it("disconnects the websocket only once after the connection is already open", async () => {
const mod = await import("./tauri-daemon-transport");
const transportFactory = mod.createTauriWebSocketTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
await Promise.resolve();
await Promise.resolve();
expect(() => transport.close()).not.toThrow();
await Promise.resolve();
expect(tauriMock.getDisconnectCount()).toBe(1);
expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1);
});
it("disconnects only once when close happens during async connect cleanup", async () => {
let resolveConnect!: (socket: any) => void;
let resolveSend!: () => void;
tauriMock.connect.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveConnect = resolve;
})
);
const mod = await import("./tauri-daemon-transport");
const transportFactory = mod.createTauriWebSocketTransportFactory();
expect(transportFactory).not.toBeNull();
const disconnect = vi.fn(async () => undefined);
const addListener = vi.fn(() => vi.fn());
const send = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveSend = resolve;
})
);
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
transport.send("queued-before-connect");
resolveConnect({ addListener, send, disconnect });
await Promise.resolve();
transport.close();
resolveSend();
await Promise.resolve();
await Promise.resolve();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it("swallows the tauri duplicate-listener cleanup error when disconnect throws synchronously", async () => {
tauriMock.connect.mockImplementationOnce(async () => ({
addListener: vi.fn(() => vi.fn()),
send: vi.fn(async () => undefined),
disconnect: vi.fn(() => {
throw new TypeError(
"undefined is not an object (evaluating 'listeners[eventId].handlerId')"
);
}),
}));
const mod = await import("./tauri-daemon-transport");
const transportFactory = mod.createTauriWebSocketTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
await Promise.resolve();
expect(() => transport.close()).not.toThrow();
});
});

View File

@@ -18,6 +18,13 @@ type TauriWebSocketModule = {
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
};
function isTauriDuplicateListenerCleanupError(error: unknown): boolean {
return (
error instanceof TypeError &&
error.message.includes("listeners[eventId].handlerId")
);
}
function toTauriOutgoingMessage(
data: string | Uint8Array | ArrayBuffer
): string | number[] {
@@ -49,6 +56,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
let disposed = false;
let opened = false;
let closeEmitted = false;
let disconnectPromise: Promise<void> | null = null;
const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => void>();
@@ -104,6 +112,21 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
}
};
const disconnectSocket = (socket: TauriWebSocketConnection) => {
if (disconnectPromise) {
return disconnectPromise;
}
disconnectPromise = Promise.resolve()
.then(() => socket.disconnect())
.catch((error) => {
if (!isTauriDuplicateListenerCleanupError(error)) {
emitError(error);
}
})
.then(() => undefined);
return disconnectPromise;
};
const connect = async () => {
try {
let module: TauriWebSocketModule | null = getTauriWebSocketModule();
@@ -122,11 +145,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
const socket = await module.connect(url);
if (disposed) {
try {
await socket.disconnect();
} catch {
// no-op
}
await disconnectSocket(socket);
return;
}
@@ -160,13 +179,8 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
}
if (closeRequested) {
try {
await ws.disconnect();
} catch (error) {
emitError(error);
} finally {
emitClose(closeRequested);
}
await disconnectSocket(ws);
emitClose(closeRequested);
}
} catch (error) {
emitError(error);
@@ -189,10 +203,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
if (disposed) return;
closeRequested = { code, reason };
if (!ws) return;
void ws
.disconnect()
.catch((error) => emitError(error))
.finally(() => emitClose(closeRequested));
void disconnectSocket(ws).finally(() => emitClose(closeRequested));
},
onMessage: (handler) => {
messageHandlers.add(handler);
@@ -230,7 +241,13 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
...transport,
close: (code?: number, reason?: string) => {
wsListenerCleanup = null;
transport.close(code, reason);
try {
transport.close(code, reason);
} catch (error) {
if (!isTauriDuplicateListenerCleanupError(error)) {
throw error;
}
}
disposed = true;
ws = null;
},

View File

@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
hasMeaningfulToolCallDetail,
isPendingToolCallDetail,
} from "./tool-call-detail-state";
describe("tool-call detail state", () => {
it("treats empty unknown payloads as not meaningful", () => {
const detail: ToolCallDetail = {
type: "unknown",
input: {},
output: null,
};
assert.strictEqual(hasMeaningfulToolCallDetail(detail), false);
});
it("treats partial unknown payloads with real values as meaningful", () => {
const detail: ToolCallDetail = {
type: "unknown",
input: { path: "src/index.ts" },
output: null,
};
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
});
it("marks running calls with no meaningful detail as pending", () => {
assert.strictEqual(
isPendingToolCallDetail({
detail: {
type: "unknown",
input: {},
output: null,
},
status: "running",
error: null,
}),
true
);
});
it("does not mark completed calls as pending", () => {
assert.strictEqual(
isPendingToolCallDetail({
detail: {
type: "unknown",
input: {},
output: null,
},
status: "completed",
error: null,
}),
false
);
});
it("treats enriched search detail as meaningful", () => {
const detail: ToolCallDetail = {
type: "search",
query: "needle",
toolName: "grep",
content: "12:needle",
filePaths: ["src/index.ts"],
};
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
});
it("treats fetch detail as meaningful", () => {
const detail: ToolCallDetail = {
type: "fetch",
url: "https://example.com",
result: "Fetched summary",
};
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
});
});

View File

@@ -0,0 +1,77 @@
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
function hasMeaningfulUnknownValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "string") {
return value.trim().length > 0;
}
if (typeof value === "number" || typeof value === "boolean") {
return true;
}
if (Array.isArray(value)) {
return value.some(hasMeaningfulUnknownValue);
}
if (typeof value === "object") {
return Object.values(value).some(hasMeaningfulUnknownValue);
}
return true;
}
export function hasMeaningfulToolCallDetail(
detail: ToolCallDetail | undefined
): boolean {
if (!detail) {
return false;
}
switch (detail.type) {
case "shell":
return true;
case "read":
return Boolean(detail.filePath || detail.content);
case "edit":
return Boolean(
detail.filePath || detail.unifiedDiff || detail.oldString || detail.newString
);
case "write":
return Boolean(detail.filePath || detail.content);
case "search":
return Boolean(
detail.query.trim().length > 0 ||
detail.content ||
(detail.filePaths && detail.filePaths.length > 0) ||
(detail.webResults && detail.webResults.length > 0) ||
(detail.annotations && detail.annotations.length > 0)
);
case "fetch":
return Boolean(detail.url || detail.result || detail.codeText);
case "worktree_setup":
return Boolean(detail.branchName || detail.worktreePath || detail.log);
case "sub_agent":
return Boolean(
detail.subAgentType ||
detail.description ||
detail.log ||
detail.actions.length > 0
);
case "plain_text":
return Boolean(detail.label || detail.text);
case "unknown":
return (
hasMeaningfulUnknownValue(detail.input) ||
hasMeaningfulUnknownValue(detail.output)
);
}
}
export function isPendingToolCallDetail(params: {
detail: ToolCallDetail | undefined;
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown | null | undefined;
}): boolean {
const isRunning =
params.status === "running" || params.status === "executing";
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
}

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