mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
37 Commits
v0.1.29
...
desktop-li
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bacfb6352e | ||
|
|
08f18da22d | ||
|
|
4ab307b10f | ||
|
|
675d825f6b | ||
|
|
1328e0cc05 | ||
|
|
f0ba64f23b | ||
|
|
22c27dd583 | ||
|
|
35da664a09 | ||
|
|
1323cb13c4 | ||
|
|
3287f2d00b | ||
|
|
d15a1451b7 | ||
|
|
9d370a18b8 | ||
|
|
865e25b3a9 | ||
|
|
82ab598426 | ||
|
|
09fae62888 | ||
|
|
7e0a220fde | ||
|
|
ed9a15c0bd | ||
|
|
b763d4358e | ||
|
|
5f2d4ac122 | ||
|
|
3bdc90a661 | ||
|
|
b5212a69c9 | ||
|
|
0bc903fa21 | ||
|
|
e2f20f0e24 | ||
|
|
30a225ce9f | ||
|
|
30dd54a318 | ||
|
|
abc8ad3fd4 | ||
|
|
4897627943 | ||
|
|
18533ef52b | ||
|
|
faa9c9c491 | ||
|
|
11f6494c20 | ||
|
|
9364da3414 | ||
|
|
ed5bc3091e | ||
|
|
8ff51eb176 | ||
|
|
90fb5e1f33 | ||
|
|
cda08ec033 | ||
|
|
21c585abec | ||
|
|
a1d0492d8d |
2
.github/workflows/desktop-release.yml
vendored
2
.github/workflows/desktop-release.yml
vendored
@@ -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
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
341
docs/PANEL-REFACTOR-PLAN.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Panel Interface Refactor Plan
|
||||
|
||||
**Goal:** Replace the hardcoded panel switch statements with a registry-based panel interface. This is a pure refactor — all product surfaces stay identical. The motivation is to prepare for split panes (VSCode-style), where each split independently renders panels.
|
||||
|
||||
## The Problem
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`, ~2084 lines) has a `renderContent()` function (line 1437) that switches on `target.kind` to render each panel type with bespoke props. The same pattern repeats in:
|
||||
|
||||
- `workspace-tab-model.ts` — switches on `target.kind` to build tab descriptors (labels, subtitles, status)
|
||||
- `workspace-tab-presentation.tsx` — switches on kind for icons and status indicators
|
||||
|
||||
Every new panel type requires editing 3+ files. This must become a registry where panels self-register.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### 1. PanelRegistration Interface
|
||||
|
||||
```typescript
|
||||
// panels/panel-registry.ts
|
||||
|
||||
interface PanelDescriptor {
|
||||
label: string;
|
||||
subtitle: string;
|
||||
titleState: "ready" | "loading";
|
||||
icon: React.ComponentType<{ size: number; color: string }>;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
}
|
||||
|
||||
interface PanelRegistration<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||
kind: K;
|
||||
component: React.ComponentType;
|
||||
useDescriptor(
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor;
|
||||
confirmClose?(
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Panel Registry
|
||||
|
||||
```typescript
|
||||
const panelRegistry = new Map<string, PanelRegistration>();
|
||||
|
||||
function registerPanel(registration: PanelRegistration): void {
|
||||
panelRegistry.set(registration.kind, registration);
|
||||
}
|
||||
|
||||
function getPanelRegistration(kind: string): PanelRegistration | undefined {
|
||||
return panelRegistry.get(kind);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. PaneContext
|
||||
|
||||
Every panel gets workspace-level context via `usePaneContext()`. No prop drilling of serverId/workspaceId through panel-specific props.
|
||||
|
||||
```typescript
|
||||
interface PaneContextValue {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
openTab(target: WorkspaceTabTarget): void;
|
||||
closeCurrentTab(): void;
|
||||
retargetCurrentTab(target: WorkspaceTabTarget): void;
|
||||
openFileInWorkspace(filePath: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. WorkspaceTabTarget stays unchanged
|
||||
|
||||
```typescript
|
||||
type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string };
|
||||
```
|
||||
|
||||
No store migration needed. `serverId` and `workspaceId` come from the pane context, not the target.
|
||||
|
||||
## Panel Implementations
|
||||
|
||||
Each panel type gets its own file that exports a `PanelRegistration`. Panels use `usePaneContext()` for workspace-level context and read their own data from stores directly.
|
||||
|
||||
### Agent Panel Example
|
||||
|
||||
```typescript
|
||||
// panels/agent-panel.ts
|
||||
|
||||
function useAgentPanelDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const agent = useSessionStore(
|
||||
(s) => s.agentsByServer.get(context.serverId)?.get(target.agentId) ?? null,
|
||||
);
|
||||
const provider = agent?.provider ?? "codex";
|
||||
const label = resolveAgentLabel(agent?.title);
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon: agentIconForProvider(provider),
|
||||
statusBucket: agent ? deriveAgentStatusBucket(agent) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function AgentPanel() {
|
||||
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={serverId}
|
||||
agentId={target.agentId}
|
||||
showExplorerSidebar={false}
|
||||
wrapWithExplorerSidebarProvider={false}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
||||
kind: "agent",
|
||||
component: AgentPanel,
|
||||
useDescriptor: useAgentPanelDescriptor,
|
||||
async confirmClose(target, context) {
|
||||
const agent = useSessionStore.getState().agentsByServer.get(context.serverId)?.get(target.agentId);
|
||||
if (agent?.status === "running") {
|
||||
return confirmDialog({ title: "Agent is still running. Close anyway?" });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Terminal Panel Example
|
||||
|
||||
```typescript
|
||||
function useTerminalPanelDescriptor(
|
||||
target: { kind: "terminal"; terminalId: string },
|
||||
_context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
// read terminal data from appropriate store
|
||||
return {
|
||||
label: "Terminal",
|
||||
subtitle: "Terminal",
|
||||
titleState: "ready",
|
||||
icon: TerminalIcon,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function TerminalPanel() {
|
||||
const { serverId, workspaceId, target, openTab } = usePaneContext();
|
||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||
return (
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceId}
|
||||
selectedTerminalId={target.terminalId}
|
||||
onSelectedTerminalIdChange={(terminalId) => {
|
||||
if (terminalId) {
|
||||
openTab({ kind: "terminal", terminalId });
|
||||
}
|
||||
}}
|
||||
hideHeader
|
||||
manageTerminalDirectorySubscription={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Draft Panel Example
|
||||
|
||||
```typescript
|
||||
function useDraftPanelDescriptor(
|
||||
_target: { kind: "draft"; draftId: string },
|
||||
_context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
return {
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
titleState: "ready",
|
||||
icon: PencilIcon,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function DraftPanel() {
|
||||
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = usePaneContext();
|
||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||
return (
|
||||
<WorkspaceDraftAgentTab
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
onCreated={(agentSnapshot) => {
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### File Panel Example
|
||||
|
||||
```typescript
|
||||
function useFilePanelDescriptor(
|
||||
target: { kind: "file"; path: string },
|
||||
_context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
|
||||
return {
|
||||
label: fileName,
|
||||
subtitle: target.path,
|
||||
titleState: "ready",
|
||||
icon: FileTextIcon,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function FilePanel() {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
invariant(target.kind === "file", "FilePanel requires file target");
|
||||
return (
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceId}
|
||||
filePath={target.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## How the Tab Bar Uses It
|
||||
|
||||
Each tab chip calls the panel's `useDescriptor` hook:
|
||||
|
||||
```typescript
|
||||
function TabChip({ tabId, target, serverId, workspaceId }: {
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const registration = getPanelRegistration(target.kind);
|
||||
invariant(registration, `No panel registration for kind: ${target.kind}`);
|
||||
const descriptor = registration.useDescriptor(target, { serverId, workspaceId });
|
||||
return (
|
||||
<TabChipChrome
|
||||
tabId={tabId}
|
||||
label={descriptor.label}
|
||||
subtitle={descriptor.subtitle}
|
||||
titleState={descriptor.titleState}
|
||||
icon={<descriptor.icon size={16} color={theme.colors.foregroundMuted} />}
|
||||
statusBucket={descriptor.statusBucket}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## How the Workspace Screen Renders Content
|
||||
|
||||
Replaces the entire `renderContent()` switch:
|
||||
|
||||
```typescript
|
||||
function PaneContent({ tabId, target, serverId, workspaceId }: {
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const registration = getPanelRegistration(target.kind);
|
||||
if (!registration) return null;
|
||||
const Component = registration.component;
|
||||
return (
|
||||
<PaneProvider value={{ serverId, workspaceId, tabId, target, ...actions }}>
|
||||
<Component />
|
||||
</PaneProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create panel registry infrastructure
|
||||
|
||||
Create the following new files:
|
||||
|
||||
- `packages/app/src/panels/panel-registry.ts` — `PanelRegistration`, `PanelDescriptor` types, registry map, `registerPanel()`, `getPanelRegistration()`
|
||||
- `packages/app/src/panels/pane-context.ts` — `PaneContextValue` type, React context, `PaneProvider`, `usePaneContext()` hook
|
||||
|
||||
### Step 2: Create panel registration files
|
||||
|
||||
Move panel-specific logic out of workspace-screen, workspace-tab-model, and workspace-tab-presentation into self-contained panel modules:
|
||||
|
||||
- `packages/app/src/panels/agent-panel.ts` — agent component wrapper + `useDescriptor` + `confirmClose`
|
||||
- `packages/app/src/panels/draft-panel.ts` — draft component wrapper + `useDescriptor`
|
||||
- `packages/app/src/panels/terminal-panel.ts` — terminal component wrapper + `useDescriptor`
|
||||
- `packages/app/src/panels/file-panel.ts` — file component wrapper + `useDescriptor`
|
||||
- `packages/app/src/panels/register-panels.ts` — imports all panels, calls `registerPanel()` for each
|
||||
|
||||
### Step 3: Refactor workspace-tab-model.ts
|
||||
|
||||
Replace the per-kind descriptor derivation in `deriveWorkspaceTabModel()` with calls to `getPanelRegistration(target.kind).useDescriptor(...)`.
|
||||
|
||||
Note: `deriveWorkspaceTabModel` is a pure function, not a hook. The `useDescriptor` hooks are called from React components (the tab bar). The model derivation may need to be restructured — the tab bar calls `useDescriptor` per tab, and the model just handles ordering and active-tab resolution.
|
||||
|
||||
### Step 4: Refactor workspace-screen.tsx renderContent()
|
||||
|
||||
Replace the `renderContent()` switch with `<PaneContent>` that uses the registry. Wire up the `PaneProvider` with the action callbacks that currently live as inline functions in the workspace screen.
|
||||
|
||||
### Step 5: Refactor workspace-tab-presentation.tsx
|
||||
|
||||
Move icon components and status derivation into each panel's registration. The shared `WorkspaceTabIcon` component becomes a thin wrapper that calls `registration.useDescriptor()` and renders the icon from the descriptor.
|
||||
|
||||
### Step 6: Verify
|
||||
|
||||
- `npm run typecheck` must pass
|
||||
- All existing tab behavior must work identically: open, close, reorder, retarget, keyboard shortcuts, context menus
|
||||
- Mobile tab switcher must work unchanged
|
||||
- No visual regressions in tab bar, icons, status indicators
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Pure refactor** — zero user-visible behavior changes
|
||||
- **No new features** — no splits, no new panel types, no new keyboard shortcuts
|
||||
- **WorkspaceTabTarget stays unchanged** — no store migration
|
||||
- **workspace-tabs-store.ts stays unchanged** — the store is not part of this refactor
|
||||
- **Do not create index.ts barrel files** — project convention
|
||||
- **Use `invariant` from `tiny-invariant`** for asserting panel target kinds
|
||||
- **Use `function` declarations** — project convention (no arrow function components)
|
||||
- **Use `interface` over `type` where possible** — project convention
|
||||
- **Run `npm run typecheck` after every change** — project rule
|
||||
@@ -32,6 +32,23 @@ npm run release:finalize # Publish npm, promote draft to published
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||
|
||||
To retry a failed workflow for an existing tag:
|
||||
|
||||
1. **Retry via `workflow_dispatch`** — all release workflows support `workflow_dispatch` with a `tag` input:
|
||||
```bash
|
||||
gh workflow run "Desktop Release" -f tag=v0.1.28 # all platforms
|
||||
gh workflow run "Desktop Release" -f tag=v0.1.28 -f platform=macos # single platform
|
||||
gh workflow run "Android APK Release" -f tag=v0.1.28
|
||||
gh workflow run "Deploy App" # no tag input needed
|
||||
```
|
||||
2. **Platform-specific retry tags** (desktop only) — push a tag like `desktop-macos-v0.1.28` to rebuild just that platform against the release tag's code
|
||||
|
||||
If the fix requires a code change (e.g. a broken build script), commit the fix to `main` and use `workflow_dispatch` pointing at the existing tag — the workflow checks out the tag ref, but for build-tooling fixes you may need to point it at `main` or cherry-pick the fix onto the tag.
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
|
||||
275
docs/SPLIT-PANES-PLAN.md
Normal file
275
docs/SPLIT-PANES-PLAN.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Split Panes Plan
|
||||
|
||||
**Goal:** VSCode-style split panes for the workspace screen. Users can drag tabs to edges to create horizontal/vertical splits, resize splits, and navigate between panes with keyboard shortcuts. Desktop/web only — mobile uses the same store but never creates splits (single pane).
|
||||
|
||||
## Data Model
|
||||
|
||||
### Core Types
|
||||
|
||||
```typescript
|
||||
interface SplitPane {
|
||||
id: string;
|
||||
tabIds: string[];
|
||||
focusedTabId: string | null;
|
||||
}
|
||||
|
||||
interface SplitGroup {
|
||||
id: string;
|
||||
direction: "horizontal" | "vertical";
|
||||
children: SplitNode[];
|
||||
sizes: number[]; // proportional, sum to 1, same length as children
|
||||
}
|
||||
|
||||
type SplitNode =
|
||||
| { kind: "pane"; pane: SplitPane }
|
||||
| { kind: "group"; group: SplitGroup };
|
||||
|
||||
interface WorkspaceLayout {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **Single store replaces the flat tab store.** The layout store owns tabs, tab order (per pane), and focused tab (per pane). No separate flat tab store.
|
||||
- **Mobile is just a single-pane tree.** Same store, same code paths. Mobile never calls split operations, so the tree never grows beyond one pane.
|
||||
- **Focused pane concept.** Common operations (`openTab`, `closeTab`, `focusTab`) route to the focused pane automatically. No `paneId` parameter needed for everyday use.
|
||||
- **`PaneContext` doesn't need `paneId`.** Split-specific operations (drag-drop, resize) are wired directly in split UI components that know their pane ID from tree rendering.
|
||||
- **Max depth: 4 levels.**
|
||||
- **Proportional sizes** that sum to 1. Minimum proportion per child: 0.1 (10%).
|
||||
|
||||
### Default State
|
||||
|
||||
Every workspace starts with:
|
||||
|
||||
```typescript
|
||||
{
|
||||
root: { kind: "pane", pane: { id: "main", tabIds: [], focusedTabId: null } },
|
||||
focusedPaneId: "main",
|
||||
}
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
Version 6 migration from the current flat tab store. Wraps existing `tabIds`, `tabOrder`, and `focusedTabId` into a single-pane tree.
|
||||
|
||||
## Store Actions
|
||||
|
||||
### Everyday Operations (pane-agnostic)
|
||||
|
||||
These don't take a `paneId`. Mobile code only uses these.
|
||||
|
||||
```typescript
|
||||
openTab(workspaceKey: string, target: WorkspaceTabTarget): string | null;
|
||||
closeTab(workspaceKey: string, tabId: string): void;
|
||||
focusTab(workspaceKey: string, tabId: string): void;
|
||||
retargetTab(workspaceKey: string, tabId: string, target: WorkspaceTabTarget): string | null;
|
||||
reorderTabs(workspaceKey: string, tabIds: string[]): void; // within focused pane
|
||||
getWorkspaceTabs(workspaceKey: string): WorkspaceTab[]; // all tabs across all panes
|
||||
```
|
||||
|
||||
- `openTab` creates the tab and adds it to the focused pane.
|
||||
- `closeTab` finds the tab in any pane, removes it. If that was the last tab in the pane, collapses the pane.
|
||||
- `focusTab` finds the tab in any pane, focuses it and focuses that pane.
|
||||
|
||||
### Split Operations (desktop only)
|
||||
|
||||
```typescript
|
||||
splitPane(workspaceKey: string, input: {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}): string | null; // new pane ID, or null if depth cap hit
|
||||
|
||||
moveTabToPane(workspaceKey: string, tabId: string, toPaneId: string): void;
|
||||
focusPane(workspaceKey: string, paneId: string): void;
|
||||
resizeSplit(workspaceKey: string, groupId: string, sizes: number[]): void;
|
||||
reorderTabsInPane(workspaceKey: string, paneId: string, tabIds: string[]): void;
|
||||
```
|
||||
|
||||
## Tree Transformations
|
||||
|
||||
### splitPane
|
||||
|
||||
**Position mapping:**
|
||||
- `left` / `right` → `horizontal` direction
|
||||
- `top` / `bottom` → `vertical` direction
|
||||
- `left` / `top` → new pane inserted before target
|
||||
- `right` / `bottom` → new pane inserted after target
|
||||
|
||||
**Optimization:** If the target pane's parent group has the same direction, insert as a sibling into that group instead of nesting. This keeps the tree flat.
|
||||
|
||||
```
|
||||
Before: horizontal([A, B])
|
||||
Split B right with tab X
|
||||
|
||||
Optimized: horizontal([A, B, C]) ← insert into existing group
|
||||
Naive: horizontal([A, horizontal([B, C])]) ← wastes depth
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Check depth — reject if would exceed 4 levels
|
||||
2. Remove `tabId` from source pane (could be same or different pane)
|
||||
3. Create new pane: `{ id: generateId(), tabIds: [tabId], focusedTabId: tabId }`
|
||||
4. If parent group has same direction → insert new pane adjacent to target in parent's children, split target's size proportion 50/50 between target and new pane
|
||||
5. Else → replace target node with new group `{ direction, children: [target, newPane], sizes: [0.5, 0.5] }` (order based on position)
|
||||
6. If source pane is now empty → collapse it
|
||||
7. Set `focusedPaneId` to new pane
|
||||
|
||||
### collapsePane
|
||||
|
||||
Triggered when a pane's last tab is removed or moved out.
|
||||
|
||||
```
|
||||
Before: horizontal([A, B, C]) sizes [0.3, 0.4, 0.3]
|
||||
B loses last tab
|
||||
|
||||
After: horizontal([A, C]) sizes [0.5, 0.5] (renormalized)
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Remove pane from parent group's children
|
||||
2. Remove corresponding entry from parent's sizes
|
||||
3. Renormalize sizes to sum to 1
|
||||
4. If parent group now has 1 child → unwrap: replace group with its single remaining child
|
||||
5. Unwrap can cascade up the tree
|
||||
6. Move focus to nearest sibling
|
||||
|
||||
### moveTabToPane
|
||||
|
||||
Tab dragged from one pane to another existing pane.
|
||||
|
||||
1. Remove `tabId` from source pane's `tabIds`
|
||||
2. Insert into target pane's `tabIds` at drop position (or end)
|
||||
3. Set target pane's `focusedTabId` to the moved tab
|
||||
4. If source pane is now empty → collapsePane
|
||||
5. Set `focusedPaneId` to target pane
|
||||
|
||||
### resizeSplit
|
||||
|
||||
User drags a divider between panes.
|
||||
|
||||
1. Find group by ID
|
||||
2. Update the two adjacent sizes based on drag delta
|
||||
3. Clamp each child to minimum proportion (0.1)
|
||||
4. Renormalize so sizes sum to 1
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Action | Shortcut |
|
||||
|---|---|
|
||||
| Split right | `Cmd+\` |
|
||||
| Split down | `Cmd+Shift+\` |
|
||||
| Focus pane left | `Cmd+Shift+←` |
|
||||
| Focus pane right | `Cmd+Shift+→` |
|
||||
| Focus pane up | `Cmd+Shift+↑` |
|
||||
| Focus pane down | `Cmd+Shift+↓` |
|
||||
| Move tab to pane left | `Cmd+Shift+Alt+←` |
|
||||
| Move tab to pane right | `Cmd+Shift+Alt+→` |
|
||||
| Move tab to pane up | `Cmd+Shift+Alt+↑` |
|
||||
| Move tab to pane down | `Cmd+Shift+Alt+↓` |
|
||||
| Close pane | `Cmd+Shift+W` |
|
||||
|
||||
Existing tab shortcuts unchanged — `Cmd+T`, `Cmd+W`, `Alt+Shift+[/]`, `Alt+1-9` — they operate on the focused pane's tabs.
|
||||
|
||||
## Drag and Drop UX
|
||||
|
||||
### Drop Zones
|
||||
|
||||
When dragging a tab over a pane, the pane is divided into 5 drop zones:
|
||||
- **Center** (inner 40%) — move tab to this pane (add to existing tab list)
|
||||
- **Left edge** (leftmost 15%) — split left
|
||||
- **Right edge** (rightmost 15%) — split right
|
||||
- **Top edge** (topmost 15%) — split up
|
||||
- **Bottom edge** (bottommost 15%) — split down
|
||||
|
||||
### Overlay Preview
|
||||
|
||||
On hover over a drop zone, show a semi-transparent overlay rectangle covering the half of the pane where the new split would appear. The overlay uses the theme's accent color at low opacity.
|
||||
|
||||
### Cross-Pane Tab Drag
|
||||
|
||||
Tabs can be dragged:
|
||||
- Within a pane's tab bar → reorder (existing behavior via SortableInlineList)
|
||||
- From one pane's tab bar to another pane's tab bar → move tab to that pane
|
||||
- From a tab bar to a pane's drop zone → split
|
||||
|
||||
When dragging the last tab out of a pane, the pane collapses after the drop completes.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Layout Store
|
||||
|
||||
Create `packages/app/src/stores/workspace-layout-store.ts`:
|
||||
- `WorkspaceLayout`, `SplitNode`, `SplitPane`, `SplitGroup` types
|
||||
- Zustand store with AsyncStorage persistence
|
||||
- Everyday actions: `openTab`, `closeTab`, `focusTab`, `retargetTab`, `reorderTabs`
|
||||
- Tree helpers: `findPaneById`, `findPaneContainingTab`, `getTreeDepth`, `collectAllTabs`
|
||||
- Version 6 migration from flat tab store
|
||||
|
||||
### Step 2: Migrate Workspace Screen to Layout Store
|
||||
|
||||
Replace all `useWorkspaceTabsStore` usage in workspace-screen with the new layout store. Mobile and desktop both use the layout store — mobile just never splits. All existing behavior preserved.
|
||||
|
||||
### Step 3: Split Tree Transformations
|
||||
|
||||
Add to the layout store:
|
||||
- `splitPane` with the parent-direction optimization and depth check
|
||||
- `collapsePane` with unwrap cascading
|
||||
- `moveTabToPane`
|
||||
- `resizeSplit`
|
||||
|
||||
Pure tree transformation functions, tested independently.
|
||||
|
||||
### Step 4: Split Container Component
|
||||
|
||||
Create `packages/app/src/components/split-container.tsx`:
|
||||
- Recursive component that renders `SplitNode`
|
||||
- Groups render as flex containers with direction from `SplitGroup.direction`
|
||||
- Panes render tab bar + active panel content (using the panel registry)
|
||||
- Resize handles between children of a group
|
||||
|
||||
### Step 5: Drop Zones and Overlay
|
||||
|
||||
Create `packages/app/src/components/split-drop-zone.tsx`:
|
||||
- Overlay that appears during tab drag
|
||||
- Divides pane into 5 zones (center + 4 edges)
|
||||
- Shows preview rectangle on hover
|
||||
- Calls `splitPane` or `moveTabToPane` on drop
|
||||
|
||||
### Step 6: Cross-Pane Drag
|
||||
|
||||
Extend the existing dnd-kit setup:
|
||||
- Tab bar items remain draggable (existing)
|
||||
- Pane drop zones become droppable targets
|
||||
- Tab bar of other panes become droppable targets (move to pane)
|
||||
- DndContext wraps the entire split container (not individual panes)
|
||||
|
||||
### Step 7: Keyboard Shortcuts
|
||||
|
||||
Register new actions in `keyboard/actions.ts`:
|
||||
- `workspace.pane.split.right`, `workspace.pane.split.down`
|
||||
- `workspace.pane.focus.left/right/up/down`
|
||||
- `workspace.pane.move-tab.left/right/up/down`
|
||||
- `workspace.pane.close`
|
||||
|
||||
Add bindings in `keyboard-shortcuts.ts` and handlers in the workspace screen.
|
||||
|
||||
### Step 8: Pane Focus Navigation
|
||||
|
||||
Implement spatial navigation for `focus.left/right/up/down`:
|
||||
- Walk the tree to find the focused pane's position in the layout
|
||||
- Find the nearest pane in the requested direction
|
||||
- Focus it
|
||||
|
||||
Same logic for `move-tab` shortcuts — find adjacent pane, call `moveTabToPane`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Mobile stays single-pane — same store, no special casing
|
||||
- Max 4 levels of nesting
|
||||
- Minimum pane size: 10% of parent
|
||||
- `PaneContext` interface unchanged — no `paneId` added
|
||||
- Panel registry unchanged — panels don't know about splits
|
||||
- Existing tab shortcuts work on focused pane, unchanged
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --prefix ../expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
@@ -26,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",
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||
import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from '@/utils/host-routes'
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
const expoPathname = usePathname()
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>()
|
||||
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
|
||||
const serverId = activeRoute?.serverId ?? ''
|
||||
const workspaceId = activeRoute?.workspaceId ?? ''
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[]
|
||||
workspaceId?: string | string[]
|
||||
open?: string | string[]
|
||||
}>()
|
||||
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId
|
||||
const workspaceValue = Array.isArray(params.workspaceId)
|
||||
? params.workspaceId[0]
|
||||
: params.workspaceId
|
||||
const serverId = serverValue?.trim() ?? ''
|
||||
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
|
||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, Shield } from "lucide-react-native";
|
||||
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -25,7 +25,23 @@ import type {
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||
import { baseColors } from "@/styles/theme";
|
||||
|
||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
};
|
||||
|
||||
const MODE_COLOR_MAP: Record<string, string> = {
|
||||
default: baseColors.blue[500],
|
||||
safe: baseColors.green[500],
|
||||
moderate: baseColors.amber[500],
|
||||
dangerous: baseColors.red[500],
|
||||
readonly: baseColors.purple[500],
|
||||
};
|
||||
|
||||
type DropdownTriggerRenderProps = {
|
||||
label: string;
|
||||
@@ -563,6 +579,10 @@ export function AgentConfigRow({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||
|
||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||
|
||||
return (
|
||||
<View style={styles.agentConfigRow}>
|
||||
<View style={styles.agentConfigColumn}>
|
||||
@@ -603,7 +623,7 @@ export function AgentConfigRow({
|
||||
placeholder="Default"
|
||||
disabled={disabled || modeOptions.length === 0}
|
||||
onSelect={onSelectMode}
|
||||
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
|
||||
showLabel={false}
|
||||
testID="draft-mode-select"
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,9 @@ describe('resolveStatusControlMode', () => {
|
||||
selectedModel: '',
|
||||
onSelectModel: () => undefined,
|
||||
isModelLoading: false,
|
||||
allProviderModels: new Map(),
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: '',
|
||||
onSelectThinkingOption: () => undefined,
|
||||
|
||||
@@ -699,7 +699,7 @@ export function AgentInputArea({
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={theme.iconSize.lg} color="white" fill="white" />
|
||||
<Square size={theme.iconSize.md} color="white" fill="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -720,15 +720,16 @@ export function AgentInputArea({
|
||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||
accessibilityLabel="Enable Voice mode"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
style={({ hovered }) => [
|
||||
styles.realtimeVoiceButton as any,
|
||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
{voice?.isVoiceSwitching ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<AudioLines size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<AudioLines size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -885,8 +886,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
zIndex: 30,
|
||||
},
|
||||
cancelButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
alignItems: 'center',
|
||||
@@ -898,12 +899,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -911,6 +909,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.palette.green[600],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, Platform, Pressable } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react-native'
|
||||
import { getProviderIcon } from '@/components/provider-icons'
|
||||
import { CombinedModelSelector } from '@/components/combined-model-selector'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import {
|
||||
@@ -10,7 +20,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -18,6 +28,11 @@ import type {
|
||||
AgentProvider,
|
||||
} from '@server/server/agent/agent-sdk-types'
|
||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
||||
import {
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
} from '@server/server/agent/provider-manifest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
|
||||
|
||||
type StatusOption = {
|
||||
@@ -26,6 +41,7 @@ type StatusOption = {
|
||||
}
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string
|
||||
providerOptions?: StatusOption[]
|
||||
selectedProviderId?: string
|
||||
onSelectProvider?: (providerId: string) => void
|
||||
@@ -53,6 +69,9 @@ export interface DraftAgentStatusBarProps {
|
||||
selectedModel: string
|
||||
onSelectModel: (modelId: string) => void
|
||||
isModelLoading: boolean
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
isAllModelsLoading: boolean
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
|
||||
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
|
||||
selectedThinkingOptionId: string
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void
|
||||
@@ -72,7 +91,36 @@ function findOptionLabel(options: StatusOption[] | undefined, selectedId: string
|
||||
return selected?.label ?? fallback
|
||||
}
|
||||
|
||||
const MODE_ICONS = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
} as const
|
||||
|
||||
|
||||
function getModeIconColor(
|
||||
colorTier: AgentModeColorTier | undefined,
|
||||
palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } }
|
||||
): string {
|
||||
switch (colorTier) {
|
||||
case 'default':
|
||||
return palette.blue[500]
|
||||
case 'safe':
|
||||
return palette.green[500]
|
||||
case 'moderate':
|
||||
return palette.amber[500]
|
||||
case 'dangerous':
|
||||
return palette.red[500]
|
||||
case 'readonly':
|
||||
return palette.purple[500]
|
||||
default:
|
||||
return palette.blue[500]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function ControlledStatusBar({
|
||||
provider,
|
||||
providerOptions,
|
||||
selectedProviderId,
|
||||
onSelectProvider,
|
||||
@@ -113,6 +161,11 @@ function ControlledStatusBar({
|
||||
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
|
||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
|
||||
|
||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
|
||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
|
||||
const ProviderIcon = getProviderIcon(provider)
|
||||
|
||||
const hasAnyControl =
|
||||
Boolean(providerOptions?.length) ||
|
||||
Boolean(modeOptions?.length) ||
|
||||
@@ -144,6 +197,23 @@ function ControlledStatusBar({
|
||||
[thinkingOptions]
|
||||
)
|
||||
|
||||
const renderModeOption = useCallback(
|
||||
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
|
||||
const visuals = getModeVisuals(provider, option.id)
|
||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[provider, theme.colors.foreground]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
|
||||
setOpenSelector(nextOpen ? selector : null)
|
||||
@@ -152,7 +222,7 @@ function ControlledStatusBar({
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
<>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
@@ -196,17 +266,20 @@ function ControlledStatusBar({
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
styles.modeIconBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent mode"
|
||||
accessibilityLabel={`Select agent mode (${displayMode})`}
|
||||
testID="agent-mode-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : (
|
||||
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModeOptions}
|
||||
@@ -217,38 +290,44 @@ function ControlledStatusBar({
|
||||
onOpenChange={handleOpenChange('mode')}
|
||||
anchorRef={modeAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
renderOption={renderModeOption}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
{canSelectModel ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<>
|
||||
@@ -264,14 +343,10 @@ function ControlledStatusBar({
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select thinking option"
|
||||
accessibilityLabel={`Select thinking option (${displayThinking})`}
|
||||
testID="agent-thinking-selector"
|
||||
>
|
||||
<Brain
|
||||
size={theme.iconSize.xs}
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={{ marginTop: 1 }}
|
||||
/>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
@@ -300,7 +375,7 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Agent preferences"
|
||||
testID="agent-preferences-button"
|
||||
>
|
||||
<SlidersHorizontal size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<SlidersHorizontal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
@@ -355,17 +430,57 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Select agent mode"
|
||||
testID="agent-preferences-mode"
|
||||
>
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : null}
|
||||
<Text style={styles.sheetSelectText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{modeOptions.map((mode) => (
|
||||
{modeOptions.map((mode) => {
|
||||
const visuals = getModeVisuals(provider, mode.id)
|
||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
leading={<Icon size={16} color={theme.colors.foreground} />}
|
||||
>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canSelectModel ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{mode.label}
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -373,36 +488,6 @@ function ControlledStatusBar({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
@@ -504,6 +589,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={agent.provider}
|
||||
modeOptions={
|
||||
modeOptions.length > 0
|
||||
? modeOptions
|
||||
@@ -555,33 +641,26 @@ export function DraftAgentStatusBar({
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const providerOptions = useMemo<StatusOption[]>(() => {
|
||||
return providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
}))
|
||||
}, [providerDefinitions])
|
||||
const isWeb = Platform.OS === 'web'
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
if (modeOptions.length === 0) {
|
||||
return [{ id: '', label: 'Default' }]
|
||||
}
|
||||
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
|
||||
return modeOptions.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
}))
|
||||
}, [modeOptions])
|
||||
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
const options: StatusOption[] = [{ id: '', label: 'Auto' }]
|
||||
for (const model of models) {
|
||||
options.push({ id: model.id, label: model.label })
|
||||
}
|
||||
return options
|
||||
}, [models])
|
||||
|
||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
|
||||
}, [thinkingOptions])
|
||||
@@ -590,8 +669,45 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
|
||||
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
isLoading={isAllModelsLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const providerOptions = providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
}))
|
||||
|
||||
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
|
||||
for (const model of models) {
|
||||
modelOptions.push({ id: model.id, label: model.label })
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerOptions={providerOptions}
|
||||
selectedProviderId={selectedProvider}
|
||||
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
|
||||
@@ -613,18 +729,26 @@ export function DraftAgentStatusBar({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-end',
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
modeBadge: {
|
||||
height: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
modeIconBadge: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
modeBadgeHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
@@ -640,8 +764,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
prefsButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -22,11 +22,10 @@ import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
@@ -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}>
|
||||
|
||||
372
packages/app/src/components/combined-model-selector.tsx
Normal file
372
packages/app/src/components/combined-model-selector.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, Pressable, Platform } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
|
||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
||||
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
|
||||
import { getProviderIcon } from '@/components/provider-icons'
|
||||
|
||||
const INLINE_MODEL_THRESHOLD = 8
|
||||
|
||||
type DrillDownView = { provider: string }
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void
|
||||
isLoading: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function CombinedModelSelector({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const anchorRef = useRef<View>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [view, setView] = useState<'groups' | DrillDownView>('groups')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (open) {
|
||||
const models = allProviderModels.get(selectedProvider)
|
||||
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
||||
setView({ provider: selectedProvider })
|
||||
}
|
||||
} else {
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
}
|
||||
}, [allProviderModels, selectedProvider])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId)
|
||||
setIsOpen(false)
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
},
|
||||
[onSelect]
|
||||
)
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider)
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
const models = allProviderModels.get(selectedProvider)
|
||||
if (!models) return isLoading ? 'Loading...' : 'Auto'
|
||||
const model = models.find((m) => m.id === selectedModel)
|
||||
return model?.label ?? 'Auto'
|
||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={() => handleOpenChange(!isOpen)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.trigger,
|
||||
hovered && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={[]}
|
||||
value=""
|
||||
onSelect={() => {}}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
title="Select model"
|
||||
>
|
||||
{view === 'groups' ? (
|
||||
<GroupsView
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={handleSelect}
|
||||
onDrillDown={(provider) => {
|
||||
setView({ provider })
|
||||
setSearchQuery('')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DrillDownModelView
|
||||
provider={view.provider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
models={allProviderModels.get(view.provider) ?? []}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={handleSelect}
|
||||
onBack={() => {
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Combobox>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsView({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
onSelect: (provider: string, modelId: string) => void
|
||||
onDrillDown: (provider: string) => void
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
|
||||
return (
|
||||
<View>
|
||||
{providerDefinitions.map((def, index) => {
|
||||
const models = allProviderModels.get(def.id) ?? []
|
||||
const isInline = models.length <= INLINE_MODEL_THRESHOLD
|
||||
const ProvIcon = getProviderIcon(def.id)
|
||||
|
||||
return (
|
||||
<View key={def.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
|
||||
{isInline ? (
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sectionHeadingText}>{def.label}</Text>
|
||||
</View>
|
||||
{models.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
selected={model.id === selectedModel && def.id === selectedProvider}
|
||||
onPress={() => onSelect(def.id, model.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => onDrillDown(def.id)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.drillDownRow,
|
||||
hovered && styles.drillDownRowHovered,
|
||||
pressed && styles.drillDownRowPressed,
|
||||
]}
|
||||
>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{def.label}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>{models.length}</Text>
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DrillDownModelView({
|
||||
provider,
|
||||
providerDefinitions,
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
provider: string
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
models: AgentModelDefinition[]
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
onSelect: (provider: string, modelId: string) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
const ProvIcon = getProviderIcon(provider)
|
||||
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return models
|
||||
const q = searchQuery.toLowerCase()
|
||||
return models.filter(
|
||||
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
|
||||
)
|
||||
}, [models, searchQuery])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.backButton,
|
||||
hovered && styles.backButtonHovered,
|
||||
pressed && styles.backButtonPressed,
|
||||
]}
|
||||
>
|
||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
|
||||
<SearchInput
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
autoFocus={Platform.OS === 'web'}
|
||||
/>
|
||||
|
||||
{filteredModels.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
description={model.description}
|
||||
selected={model.id === selectedModel && provider === selectedProvider}
|
||||
onPress={() => onSelect(provider, model.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredModels.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[1],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
sectionHeadingText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
drillDownRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
},
|
||||
drillDownRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
drillDownRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
drillDownText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
drillDownTrailing: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
drillDownCount: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
backButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}))
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { memo, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||
import { Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
@@ -69,6 +69,9 @@ export function CommandCenter() {
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") {
|
||||
return;
|
||||
@@ -99,18 +102,12 @@ export function CommandCenter() {
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
}, [activeIndex]);
|
||||
}, [activeIndex, open]);
|
||||
|
||||
if (Platform.OS !== "web") return null;
|
||||
if (Platform.OS !== "web" || !open) return null;
|
||||
|
||||
const actionItems = useMemo(
|
||||
() => items.filter((item) => item.kind === "action"),
|
||||
[items]
|
||||
);
|
||||
const agentItems = useMemo(
|
||||
() => items.filter((item) => item.kind === "agent"),
|
||||
[items]
|
||||
);
|
||||
const actionItems = items.filter((item) => item.kind === "action");
|
||||
const agentItems = items.filter((item) => item.kind === "agent");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -479,7 +479,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -23,6 +23,7 @@ import Animated, {
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
@@ -581,13 +582,12 @@ export function FileExplorerPane({
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
@@ -875,7 +875,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
height: 32 + theme.spacing[2] * 2,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
@@ -898,7 +898,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
sortButton: {
|
||||
height: 32,
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1025,8 +1025,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
iconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1034,10 +1034,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonPressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.96 }],
|
||||
},
|
||||
refreshIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import {
|
||||
@@ -206,7 +207,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
<View
|
||||
style={[
|
||||
styles.fileSectionHeaderContainer,
|
||||
!isExpanded && styles.fileSectionBorder,
|
||||
isExpanded && styles.fileSectionHeaderExpanded,
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
layoutYRef.current = event.nativeEvent.layout.y;
|
||||
@@ -1086,11 +1087,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
},
|
||||
diffStatusContainer: {
|
||||
paddingVertical: 1.5,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
diffStatusInner: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
@@ -1190,6 +1192,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
fileSectionHeaderContainer: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
fileSectionHeaderExpanded: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
fileSectionBodyContainer: {
|
||||
@@ -1208,7 +1212,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
zIndex: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,8 @@ import Animated, {
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { usePanelStore } from '@/stores/panel-store'
|
||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -944,9 +944,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]}
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<Paperclip size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Attach images</Text>
|
||||
@@ -972,18 +976,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
? 'Stop dictation'
|
||||
: 'Start dictation'
|
||||
}
|
||||
style={[
|
||||
style={({ hovered }) => [
|
||||
styles.voiceButton,
|
||||
hovered && !isDictating && styles.iconButtonHovered,
|
||||
(!isDictationStartEnabled) && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
]}
|
||||
>
|
||||
{isDictating ? (
|
||||
<Square size={theme.iconSize.lg} color="white" fill="white" />
|
||||
<Square size={theme.iconSize.md} color="white" fill="white" />
|
||||
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
||||
<MicOff size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<MicOff size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -1010,9 +1015,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Queue message"
|
||||
accessibilityRole="button"
|
||||
style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]}
|
||||
style={({ hovered }) => [
|
||||
styles.queueButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Plus size={theme.iconSize.lg} color="white" />
|
||||
<Plus size={theme.iconSize.md} color="white" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
@@ -1034,7 +1043,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
{isSubmitLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<ArrowUp size={theme.iconSize.lg} color="white" />
|
||||
<ArrowUp size={theme.iconSize.md} color="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -1156,9 +1165,9 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
textInput: {
|
||||
width: '100%',
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: theme.fontSize.lg * 1.4,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
...(IS_WEB
|
||||
? {
|
||||
outlineStyle: 'none' as const,
|
||||
@@ -1174,7 +1183,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
},
|
||||
leftButtonGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-end',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
@@ -1183,15 +1192,15 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
attachButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
voiceButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -1200,21 +1209,24 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
backgroundColor: theme.colors.destructive,
|
||||
},
|
||||
queueButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -71,6 +71,10 @@ import {
|
||||
buildToolCallDisplayModel,
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
@@ -537,6 +541,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||
alignSelf: "flex-start",
|
||||
padding: theme.spacing[2],
|
||||
paddingTop: 0,
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -620,7 +625,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
|
||||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginHorizontal: -6,
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
@@ -631,8 +636,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
pressable: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: "transparent",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
overflow: "hidden",
|
||||
@@ -656,16 +660,18 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderRadius: 11,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: theme.spacing[2],
|
||||
marginRight: theme.spacing[1],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.88,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 0,
|
||||
},
|
||||
labelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
labelLoading: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.72,
|
||||
@@ -677,6 +683,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
secondaryLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
shimmerText: {
|
||||
color: "transparent",
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -706,6 +715,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
pressableExpanded: {
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
@@ -748,11 +759,11 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
workspaceRoot,
|
||||
disableOuterSpacing,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { theme, rt } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||
|
||||
const markdownParser = useMemo(
|
||||
() => {
|
||||
@@ -937,6 +948,22 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
parent: any,
|
||||
styles: any,
|
||||
) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View
|
||||
key={node.key}
|
||||
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
link: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
@@ -1590,12 +1617,23 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
[isExpanded, isInteractive]
|
||||
);
|
||||
|
||||
const isActive = isHovered || isExpanded;
|
||||
|
||||
const labelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.label,
|
||||
isActive && expandableBadgeStylesheet.labelActive,
|
||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||
],
|
||||
[isLoading]
|
||||
[isActive, isLoading]
|
||||
);
|
||||
|
||||
const secondaryLabelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.secondaryLabel,
|
||||
isActive && expandableBadgeStylesheet.secondaryLabelActive,
|
||||
],
|
||||
[isActive]
|
||||
);
|
||||
|
||||
const shimmerLabelTextStyle = useMemo(
|
||||
@@ -1666,7 +1704,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const IconComponent = icon;
|
||||
const iconColor = isError
|
||||
? theme.colors.destructive
|
||||
: theme.colors.mutedForeground;
|
||||
: isActive
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground;
|
||||
|
||||
let iconNode: ReactNode = null;
|
||||
if (isError) {
|
||||
@@ -1713,7 +1753,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={expandableBadgeStylesheet.secondaryLabel}
|
||||
style={secondaryLabelStyle}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
|
||||
>
|
||||
@@ -1808,7 +1848,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
{isInteractive && isHovered ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
color={theme.colors.foreground}
|
||||
style={chevronStyle}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1923,31 +1963,42 @@ export const ToolCall = memo(function ToolCall({
|
||||
const summary = displayModel.summary;
|
||||
const errorText = displayModel.errorText;
|
||||
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: effectiveDetail,
|
||||
status,
|
||||
error,
|
||||
});
|
||||
const secondaryLabel = summary;
|
||||
|
||||
// Check if there's any content to display
|
||||
const hasDetails =
|
||||
Boolean(error) ||
|
||||
(effectiveDetail
|
||||
? effectiveDetail.type === "plain_text"
|
||||
? Boolean(effectiveDetail.text)
|
||||
: effectiveDetail.type !== "unknown" ||
|
||||
effectiveDetail.input !== null ||
|
||||
effectiveDetail.output !== null
|
||||
: false);
|
||||
hasMeaningfulToolCallDetail(effectiveDetail);
|
||||
const canOpenDetails = hasDetails || isLoadingDetails;
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
displayName,
|
||||
summary,
|
||||
summary: secondaryLabel,
|
||||
detail: effectiveDetail,
|
||||
errorText,
|
||||
showLoadingSkeleton: isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isMobile, openToolCall, toolName, displayName, summary, effectiveDetail, errorText]);
|
||||
}, [
|
||||
isMobile,
|
||||
openToolCall,
|
||||
toolName,
|
||||
displayName,
|
||||
secondaryLabel,
|
||||
effectiveDetail,
|
||||
errorText,
|
||||
isLoadingDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||
@@ -1984,19 +2035,20 @@ export const ToolCall = memo(function ToolCall({
|
||||
detail={effectiveDetail}
|
||||
errorText={errorText}
|
||||
maxHeight={400}
|
||||
showLoadingSkeleton={isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [isMobile, effectiveDetail, errorText]);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={displayName}
|
||||
secondaryLabel={summary}
|
||||
secondaryLabel={secondaryLabel}
|
||||
icon={IconComponent}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
onToggle={hasDetails ? handleToggle : undefined}
|
||||
renderDetails={hasDetails && !isMobile ? renderDetails : undefined}
|
||||
onToggle={canOpenDetails ? handleToggle : undefined}
|
||||
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
isLoading={status === "running" || status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
@@ -294,7 +295,7 @@ export function ProjectPickerModal() {
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{path}
|
||||
{shortenPath(path)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
12
packages/app/src/components/provider-icons.ts
Normal file
12
packages/app/src/components/provider-icons.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Bot } from 'lucide-react-native'
|
||||
import { ClaudeIcon } from '@/components/icons/claude-icon'
|
||||
import { CodexIcon } from '@/components/icons/codex-icon'
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
return PROVIDER_ICONS[provider] ?? Bot
|
||||
}
|
||||
190
packages/app/src/components/resize-handle.tsx
Normal file
190
packages/app/src/components/resize-handle.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
direction: "horizontal" | "vertical";
|
||||
groupId: string;
|
||||
index: number;
|
||||
sizes: number[];
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
}
|
||||
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
leftSize: number;
|
||||
rightSize: number;
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
direction,
|
||||
groupId,
|
||||
index,
|
||||
sizes,
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pointerStateRef = useRef<PointerState | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const highlighted = active || dragging;
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: any) => {
|
||||
const hitAreaElement = event.currentTarget as HTMLElement | null;
|
||||
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerElement.getBoundingClientRect();
|
||||
const containerSize = direction === "horizontal" ? rect.width : rect.height;
|
||||
if (containerSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging(true);
|
||||
|
||||
pointerStateRef.current = {
|
||||
containerSize,
|
||||
pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
|
||||
leftSize: sizes[index] ?? 0,
|
||||
rightSize: sizes[index + 1] ?? 0,
|
||||
};
|
||||
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
|
||||
document.body.style.cursor = nextCursor;
|
||||
event.preventDefault();
|
||||
|
||||
function cleanup() {
|
||||
pointerStateRef.current = null;
|
||||
setDragging(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
const pointerState = pointerStateRef.current;
|
||||
if (!pointerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerCurrent =
|
||||
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
const nextSizes = sizes.slice();
|
||||
nextSizes[index] = pointerState.leftSize + deltaRatio;
|
||||
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
|
||||
onResizeSplit(groupId, nextSizes);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.handle,
|
||||
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
|
||||
{ backgroundColor: theme.colors.border },
|
||||
]}
|
||||
>
|
||||
{highlighted && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.highlight,
|
||||
direction === "horizontal"
|
||||
? styles.highlightHorizontal
|
||||
: styles.highlightVertical,
|
||||
{ backgroundColor: theme.colors.accent },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
role="separator"
|
||||
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
||||
style={[
|
||||
styles.hitArea,
|
||||
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
||||
{
|
||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||
} as any,
|
||||
]}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={() => {
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setActive(true);
|
||||
}, 150);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
setActive(false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((_theme) => ({
|
||||
handle: {
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
},
|
||||
handleHorizontal: {
|
||||
width: 1,
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
handleVertical: {
|
||||
height: 1,
|
||||
width: "100%",
|
||||
},
|
||||
highlight: {
|
||||
position: "absolute",
|
||||
zIndex: 5,
|
||||
},
|
||||
highlightHorizontal: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
left: -1,
|
||||
},
|
||||
highlightVertical: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
top: -1,
|
||||
},
|
||||
hitArea: {
|
||||
position: "absolute",
|
||||
zIndex: 10,
|
||||
},
|
||||
hitAreaHorizontal: {
|
||||
left: -5,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
},
|
||||
hitAreaVertical: {
|
||||
top: -5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 10,
|
||||
},
|
||||
}));
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type MutableRefObject,
|
||||
} from 'react'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { navigateToWorkspace } from '@/hooks/use-workspace-navigation'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -12,6 +12,9 @@ export function SortableInlineList<T>({
|
||||
onDragEnd?: (data: T[]) => void;
|
||||
useDragHandle?: boolean;
|
||||
disabled?: boolean;
|
||||
externalDndContext?: boolean;
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -33,6 +33,8 @@ function SortableItem<T>({
|
||||
activeId,
|
||||
useDragHandle,
|
||||
disabled,
|
||||
itemData,
|
||||
externalDndContext,
|
||||
}: {
|
||||
id: string;
|
||||
item: T;
|
||||
@@ -41,6 +43,8 @@ function SortableItem<T>({
|
||||
activeId: string | null;
|
||||
useDragHandle: boolean;
|
||||
disabled: boolean;
|
||||
itemData?: Record<string, unknown>;
|
||||
externalDndContext: boolean;
|
||||
}): ReactElement {
|
||||
const {
|
||||
attributes,
|
||||
@@ -50,24 +54,27 @@ function SortableItem<T>({
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled });
|
||||
} = useSortable({ id, disabled, data: itemData });
|
||||
|
||||
const drag = useCallback(() => {
|
||||
// dnd-kit handles drag initiation via listeners
|
||||
// This is a no-op but matches the mobile API
|
||||
}, []);
|
||||
|
||||
// See `draggable-list.web.tsx` for details on why we zero out dnd-kit scale.
|
||||
const baseTransform = CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
);
|
||||
const scaleTransform = isDragging ? "scale(1.01)" : "";
|
||||
// External DnD contexts render their own insertion affordance, so keep the
|
||||
// tab row static and let the DragOverlay carry the moving chip.
|
||||
const baseTransform = externalDndContext
|
||||
? undefined
|
||||
: CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
);
|
||||
const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
|
||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||
|
||||
const style = {
|
||||
transform: combinedTransform || undefined,
|
||||
transition,
|
||||
opacity: isDragging ? 0.9 : 1,
|
||||
opacity: externalDndContext && isDragging ? 0.3 : isDragging ? 0.9 : 1,
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
};
|
||||
|
||||
@@ -107,6 +114,9 @@ export function SortableInlineList<T>({
|
||||
disabled = false,
|
||||
activationDistance = 8,
|
||||
onDragBegin,
|
||||
externalDndContext = false,
|
||||
activeId: externalActiveId = null,
|
||||
getItemData,
|
||||
}: {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
@@ -116,10 +126,13 @@ export function SortableInlineList<T>({
|
||||
disabled?: boolean;
|
||||
activationDistance?: number;
|
||||
onDragBegin?: () => void;
|
||||
externalDndContext?: boolean;
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||
const items = dragItems ?? data;
|
||||
const items = externalDndContext ? data : dragItems ?? data;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -175,6 +188,32 @@ export function SortableInlineList<T>({
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
|
||||
const renderedItems = (
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={externalDndContext ? externalActiveId : activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
itemData={getItemData?.(item, index)}
|
||||
externalDndContext={externalDndContext}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
);
|
||||
|
||||
if (externalDndContext) {
|
||||
return renderedItems;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -183,23 +222,7 @@ export function SortableInlineList<T>({
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
{renderedItems}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
|
||||
describe("shouldFocusPaneFromEventTarget", () => {
|
||||
it("returns false for links and buttons", () => {
|
||||
expect(
|
||||
shouldFocusPaneFromEventTarget({
|
||||
closest: () => ({ tagName: "A" } as Element),
|
||||
} as unknown as EventTarget)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldFocusPaneFromEventTarget({
|
||||
closest: () => ({ tagName: "BUTTON" } as Element),
|
||||
} as unknown as EventTarget)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for non-interactive pane content", () => {
|
||||
expect(
|
||||
shouldFocusPaneFromEventTarget({
|
||||
closest: () => null,
|
||||
} as unknown as EventTarget)
|
||||
).toBe(true);
|
||||
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
20
packages/app/src/components/split-container-pane-focus.ts
Normal file
20
packages/app/src/components/split-container-pane-focus.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
const INTERACTIVE_TARGET_SELECTOR = [
|
||||
"a",
|
||||
"button",
|
||||
"input",
|
||||
"select",
|
||||
"textarea",
|
||||
"[role='button']",
|
||||
"[role='link']",
|
||||
"[contenteditable='true']",
|
||||
"[data-paseo-pane-focus-exempt='true']",
|
||||
].join(", ");
|
||||
|
||||
export function shouldFocusPaneFromEventTarget(target: EventTarget | null): boolean {
|
||||
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
|
||||
if (!candidate || typeof candidate.closest !== "function") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !candidate.closest(INTERACTIVE_TARGET_SELECTOR);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeTabDropPreview } from "@/components/split-container-tab-drop-preview";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
function tab(tabId: string): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: tabId,
|
||||
tabId,
|
||||
kind: "draft",
|
||||
target: {
|
||||
kind: "draft",
|
||||
draftId: tabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeTabDropPreview", () => {
|
||||
const targetTabs = [tab("a"), tab("b"), tab("c"), tab("d")];
|
||||
|
||||
it("returns a before-target insertion index for cross-pane drops on the left half", () => {
|
||||
expect(
|
||||
computeTabDropPreview({
|
||||
activePaneId: "source",
|
||||
activeTabId: "x",
|
||||
overPaneId: "target",
|
||||
overTabId: "c",
|
||||
targetTabs,
|
||||
activeRect: { left: 180, width: 40 },
|
||||
overRect: { left: 200, width: 100 },
|
||||
})
|
||||
).toEqual({
|
||||
paneId: "target",
|
||||
insertionIndex: 2,
|
||||
indicatorIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an after-target insertion index for cross-pane drops on the right half", () => {
|
||||
expect(
|
||||
computeTabDropPreview({
|
||||
activePaneId: "source",
|
||||
activeTabId: "x",
|
||||
overPaneId: "target",
|
||||
overTabId: "c",
|
||||
targetTabs,
|
||||
activeRect: { left: 280, width: 40 },
|
||||
overRect: { left: 200, width: 100 },
|
||||
})
|
||||
).toEqual({
|
||||
paneId: "target",
|
||||
insertionIndex: 3,
|
||||
indicatorIndex: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("adjusts same-pane drops so insertion indexes match arrayMove semantics", () => {
|
||||
expect(
|
||||
computeTabDropPreview({
|
||||
activePaneId: "pane",
|
||||
activeTabId: "b",
|
||||
overPaneId: "pane",
|
||||
overTabId: "d",
|
||||
targetTabs,
|
||||
activeRect: { left: 460, width: 40 },
|
||||
overRect: { left: 400, width: 100 },
|
||||
})
|
||||
).toEqual({
|
||||
paneId: "pane",
|
||||
insertionIndex: 3,
|
||||
indicatorIndex: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export interface TabDropPreview {
|
||||
paneId: string;
|
||||
insertionIndex: number;
|
||||
indicatorIndex: number;
|
||||
}
|
||||
|
||||
interface ComputeTabDropPreviewInput {
|
||||
activePaneId: string;
|
||||
activeTabId: string;
|
||||
overPaneId: string;
|
||||
overTabId: string;
|
||||
targetTabs: WorkspaceTabDescriptor[];
|
||||
activeRect: {
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
overRect: {
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function computeTabDropPreview(
|
||||
input: ComputeTabDropPreviewInput
|
||||
): TabDropPreview | null {
|
||||
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
|
||||
if (targetIndex < 0 || input.overRect.width <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeCenterX = input.activeRect.left + input.activeRect.width / 2;
|
||||
const overCenterX = input.overRect.left + input.overRect.width / 2;
|
||||
const insertAfterTarget = activeCenterX >= overCenterX;
|
||||
|
||||
const indicatorIndex = targetIndex + (insertAfterTarget ? 1 : 0);
|
||||
let insertionIndex = indicatorIndex;
|
||||
if (input.activePaneId === input.overPaneId) {
|
||||
const sourceIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.activeTabId);
|
||||
if (sourceIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
if (sourceIndex < insertionIndex) {
|
||||
insertionIndex -= 1;
|
||||
}
|
||||
insertionIndex = Math.max(0, Math.min(input.targetTabs.length - 1, insertionIndex));
|
||||
}
|
||||
|
||||
return {
|
||||
paneId: input.overPaneId,
|
||||
insertionIndex,
|
||||
indicatorIndex,
|
||||
};
|
||||
}
|
||||
913
packages/app/src/components/split-container.tsx
Normal file
913
packages/app/src/components/split-container.tsx
Normal file
@@ -0,0 +1,913 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { Platform, View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import {
|
||||
computeTabDropPreview,
|
||||
type TabDropPreview,
|
||||
} from "@/components/split-container-tab-drop-preview";
|
||||
import {
|
||||
SplitDropZone,
|
||||
resolveSplitDropPosition,
|
||||
type SplitDropZoneHover,
|
||||
} from "@/components/split-drop-zone";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
getWorkspacePaneDescriptors,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import {
|
||||
useWorkspaceTabPresentation,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
workspaceKey: string;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
hoveredCloseTabKey: string | null;
|
||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
|
||||
killTerminalPending: boolean;
|
||||
killTerminalId: string | null;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
|
||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||
newTabAgentOptionId?: "__new_tab_agent__";
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
}) => WorkspacePaneContentModel;
|
||||
onFocusPane: (paneId: string) => void;
|
||||
onSplitPane: (input: {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
onSplitPaneEmpty: (input: {
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
renderPaneEmptyState?: () => ReactNode;
|
||||
}
|
||||
|
||||
interface WorkspaceTabDragData {
|
||||
kind: "workspace-tab";
|
||||
paneId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface SplitPaneDropData {
|
||||
kind: "split-pane-drop";
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
interface SplitNodeViewProps
|
||||
extends Omit<SplitContainerProps, "layout"> {
|
||||
node: SplitNode;
|
||||
uiTabs: WorkspaceTab[];
|
||||
focusedPaneId: string;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
}
|
||||
|
||||
interface SplitPaneViewProps
|
||||
extends Omit<
|
||||
SplitNodeViewProps,
|
||||
| "node"
|
||||
| "workspaceKey"
|
||||
| "focusedPaneId"
|
||||
| "activeDragTabId"
|
||||
| "showDropZones"
|
||||
| "dropPreview"
|
||||
| "onMoveTabToPane"
|
||||
| "onResizeSplit"
|
||||
> {
|
||||
pane: SplitPane;
|
||||
uiTabs: WorkspaceTab[];
|
||||
isFocused: boolean;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
}
|
||||
|
||||
const dropCollisionDetection: CollisionDetection = (args) => {
|
||||
const pointerHits = pointerWithin(args);
|
||||
const tabHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab"
|
||||
);
|
||||
if (tabHits.length > 0) {
|
||||
return tabHits;
|
||||
}
|
||||
|
||||
const paneHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop"
|
||||
);
|
||||
if (paneHits.length > 0) {
|
||||
return paneHits;
|
||||
}
|
||||
|
||||
return closestCenter(args);
|
||||
};
|
||||
|
||||
export function SplitContainer({
|
||||
layout,
|
||||
workspaceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
uiTabs,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalPending,
|
||||
killTerminalId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId = "__new_tab_agent__",
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState = () => null,
|
||||
}: SplitContainerProps) {
|
||||
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
if (data?.kind !== "workspace-tab") {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
setActiveDragTabId(data.tabId);
|
||||
}, []);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
}, []);
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(
|
||||
event:
|
||||
| Pick<DragMoveEvent, "active" | "over">
|
||||
| Pick<DragOverEvent, "active" | "over">
|
||||
) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
|
||||
if (activeData?.kind !== "workspace-tab") {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const translatedRect = event.active.rect.current.translated;
|
||||
const overRect = event.over?.rect;
|
||||
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!targetPane) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTabs = getWorkspacePaneDescriptors({
|
||||
pane: targetPane,
|
||||
tabs: uiTabs,
|
||||
});
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(
|
||||
computeTabDropPreview({
|
||||
activePaneId: activeData.paneId,
|
||||
activeTabId: activeData.tabId,
|
||||
overPaneId: overData.paneId,
|
||||
overTabId: overData.tabId,
|
||||
targetTabs,
|
||||
activeRect: {
|
||||
left: translatedRect.left,
|
||||
width: translatedRect.width,
|
||||
},
|
||||
overRect: {
|
||||
left: overRect.left,
|
||||
width: overRect.width,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setTabDropPreview(null);
|
||||
if (overData?.kind !== "split-pane-drop") {
|
||||
setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const centerX = translatedRect.left + translatedRect.width / 2;
|
||||
const centerY = translatedRect.top + translatedRect.height / 2;
|
||||
const relativeX = centerX - overRect.left;
|
||||
const relativeY = centerY - overRect.top;
|
||||
if (
|
||||
Number.isNaN(relativeX) ||
|
||||
Number.isNaN(relativeY) ||
|
||||
relativeX < 0 ||
|
||||
relativeX > overRect.width ||
|
||||
relativeY < 0 ||
|
||||
relativeY > overRect.height
|
||||
) {
|
||||
setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDropPreview({
|
||||
paneId: overData.paneId,
|
||||
position: resolveSplitDropPosition({
|
||||
width: overRect.width,
|
||||
height: overRect.height,
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
}),
|
||||
});
|
||||
},
|
||||
[panesById, uiTabs]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
if (activeData?.kind !== "workspace-tab" || !event.over) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
const sourcePane = panesById.get(activeData.paneId) ?? null;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!sourcePane || !targetPane) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||
const resolvedTabDropPreview =
|
||||
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
|
||||
if (sourceIndex < 0 || !resolvedTabDropPreview) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeData.paneId === overData.paneId) {
|
||||
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
||||
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
|
||||
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
|
||||
}
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
|
||||
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) {
|
||||
if (dropPreview.position === "center") {
|
||||
if (activeData.paneId !== overData.paneId) {
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
}
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSplitPane({
|
||||
tabId: activeData.tabId,
|
||||
targetPaneId: overData.paneId,
|
||||
position: dropPreview.position,
|
||||
});
|
||||
}
|
||||
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
},
|
||||
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={dropCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={updateDropPreview}
|
||||
onDragOver={updateDropPreview}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SplitNodeView
|
||||
node={layout.root}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragTabId ? (
|
||||
<DragOverlayTabChip
|
||||
tabId={activeDragTabId}
|
||||
uiTabs={uiTabs}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChip({
|
||||
tabId,
|
||||
uiTabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tabId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const tab = uiTabs.find((t) => t.tabId === tabId);
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
const descriptor: WorkspaceTabDescriptor = {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
};
|
||||
return (
|
||||
<DragOverlayTabChipInner
|
||||
tab={descriptor}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChipInner({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const presentation = useWorkspaceTabPresentation({
|
||||
tab,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const label =
|
||||
presentation.titleState === "loading" ? "Loading..." : presentation.label;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.dragOverlayChip,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.dragOverlayLabel,
|
||||
{ color: theme.colors.foreground },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitNodeView({
|
||||
node,
|
||||
workspaceKey,
|
||||
uiTabs,
|
||||
focusedPaneId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalPending,
|
||||
killTerminalId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
activeDragTabId,
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
}: SplitNodeViewProps) {
|
||||
if (node.kind === "pane") {
|
||||
return (
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const groupSizes =
|
||||
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ??
|
||||
node.group.sizes;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.group,
|
||||
node.group.direction === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
|
||||
]}
|
||||
>
|
||||
{node.group.children.map((child, index) => (
|
||||
<Fragment key={getNodeKey(child)}>
|
||||
<View style={[styles.groupChild, { flex: groupSizes[index] ?? 1 }]}>
|
||||
<SplitNodeView
|
||||
node={child}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
</View>
|
||||
{index < node.group.children.length - 1 ? (
|
||||
<ResizeHandle
|
||||
direction={node.group.direction}
|
||||
groupId={node.group.id}
|
||||
index={index}
|
||||
sizes={groupSizes}
|
||||
onResizeSplit={onResizeSplit}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitPaneView({
|
||||
pane,
|
||||
uiTabs,
|
||||
isFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalPending,
|
||||
killTerminalId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
activeDragTabId,
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
}: SplitPaneViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const paneRef = useRef<View | null>(null);
|
||||
const paneState = useMemo(
|
||||
() =>
|
||||
deriveWorkspacePaneState({
|
||||
pane,
|
||||
tabs: uiTabs,
|
||||
}),
|
||||
[pane, uiTabs]
|
||||
);
|
||||
const paneTabs = useMemo(
|
||||
() => paneState.tabs.map((tab) => tab.descriptor),
|
||||
[paneState.tabs]
|
||||
);
|
||||
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
|
||||
() =>
|
||||
paneTabs.map((tab) => {
|
||||
const isClosingAgent =
|
||||
tab.target.kind === "agent" &&
|
||||
isArchivingAgent({
|
||||
serverId: normalizedServerId,
|
||||
agentId: tab.target.agentId,
|
||||
});
|
||||
const isClosingTerminal =
|
||||
tab.target.kind === "terminal" &&
|
||||
killTerminalPending &&
|
||||
killTerminalId === tab.target.terminalId;
|
||||
|
||||
return {
|
||||
tab,
|
||||
isActive: tab.key === activeTabDescriptor?.key,
|
||||
isCloseHovered: hoveredCloseTabKey === tab.key,
|
||||
isClosingTab: isClosingAgent || isClosingTerminal,
|
||||
};
|
||||
}),
|
||||
[
|
||||
activeTabDescriptor?.key,
|
||||
hoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalId,
|
||||
killTerminalPending,
|
||||
normalizedServerId,
|
||||
paneTabs,
|
||||
]
|
||||
);
|
||||
const paneContent = useMemo(
|
||||
() =>
|
||||
activeTabDescriptor
|
||||
? buildPaneContentModel({
|
||||
paneId: pane.id,
|
||||
tab: activeTabDescriptor,
|
||||
})
|
||||
: null,
|
||||
[
|
||||
activeTabDescriptor,
|
||||
buildPaneContentModel,
|
||||
pane.id,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
|
||||
const paneElement = paneRef.current as unknown as HTMLElement | null;
|
||||
if (
|
||||
!paneElement ||
|
||||
typeof paneElement.addEventListener !== "function" ||
|
||||
typeof paneElement.removeEventListener !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePanePointerDown = (event: PointerEvent) => {
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
onFocusPane(pane.id);
|
||||
};
|
||||
|
||||
const handlePaneFocusIn = (event: FocusEvent) => {
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
onFocusPane(pane.id);
|
||||
};
|
||||
|
||||
paneElement.addEventListener("pointerdown", handlePanePointerDown, true);
|
||||
paneElement.addEventListener("focusin", handlePaneFocusIn, true);
|
||||
|
||||
return () => {
|
||||
paneElement.removeEventListener("pointerdown", handlePanePointerDown, true);
|
||||
paneElement.removeEventListener("focusin", handlePaneFocusIn, true);
|
||||
};
|
||||
}, [onFocusPane, pane.id]);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={paneRef}
|
||||
collapsable={false}
|
||||
style={styles.pane}
|
||||
>
|
||||
<View style={styles.paneTabs}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
||||
onReorderTabs={(nextTabs) => {
|
||||
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
|
||||
}}
|
||||
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
|
||||
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
|
||||
externalDndContext
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{paneContent ? (
|
||||
<WorkspacePaneContent content={paneContent} />
|
||||
) : (
|
||||
renderPaneEmptyState?.() ?? null
|
||||
)}
|
||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
|
||||
const next = new Map<string, SplitPane>();
|
||||
function visit(current: SplitNode) {
|
||||
if (current.kind === "pane") {
|
||||
next.set(current.pane.id, current.pane);
|
||||
return;
|
||||
}
|
||||
for (const child of current.group.children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
visit(node);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getNodeKey(node: SplitNode): string {
|
||||
if (node.kind === "pane") {
|
||||
return node.pane.id;
|
||||
}
|
||||
return node.group.id;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
group: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
groupHorizontal: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
groupVertical: {
|
||||
flexDirection: "column",
|
||||
},
|
||||
groupChild: {
|
||||
flexBasis: 0,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
pane: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
paneTabs: {
|
||||
minWidth: 0,
|
||||
},
|
||||
paneContent: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
dragOverlayChip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
maxWidth: 200,
|
||||
},
|
||||
dragOverlayLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
208
packages/app/src/components/split-drop-zone.tsx
Normal file
208
packages/app/src/components/split-drop-zone.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useMemo } from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom";
|
||||
|
||||
export interface SplitDropZoneHover {
|
||||
paneId: string;
|
||||
position: SplitDropZonePosition;
|
||||
}
|
||||
|
||||
export interface SplitDropZoneProps {
|
||||
paneId: string;
|
||||
active: boolean;
|
||||
preview: SplitDropZoneHover | null;
|
||||
}
|
||||
|
||||
const EDGE_RATIO = 0.15;
|
||||
const CENTER_RATIO = 0.4;
|
||||
|
||||
export function buildSplitDropZoneId(paneId: string): string {
|
||||
return `split-pane-drop:${paneId}`;
|
||||
}
|
||||
|
||||
export function SplitDropZone({
|
||||
paneId,
|
||||
active,
|
||||
preview,
|
||||
}: SplitDropZoneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: buildSplitDropZoneId(paneId),
|
||||
disabled: !active,
|
||||
data: {
|
||||
kind: "split-pane-drop",
|
||||
paneId,
|
||||
},
|
||||
});
|
||||
|
||||
const previewStyles = useMemo(() => {
|
||||
if (!preview || preview.paneId !== paneId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
overlay: [
|
||||
styles.previewOverlay,
|
||||
getPreviewOverlayStyle(preview.position),
|
||||
{
|
||||
backgroundColor: theme.colors.accent,
|
||||
opacity: 0.6,
|
||||
},
|
||||
],
|
||||
frame: [
|
||||
styles.previewFrame,
|
||||
getPreviewFrameStyle(preview.position),
|
||||
{
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [paneId, preview, theme.colors.accent]);
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={setNodeRef as any}
|
||||
style={styles.overlay}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{previewStyles ? (
|
||||
<>
|
||||
<View pointerEvents="none" style={previewStyles.overlay} />
|
||||
<View pointerEvents="none" style={previewStyles.frame} />
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSplitDropPosition(input: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}): SplitDropZonePosition {
|
||||
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
|
||||
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
|
||||
const insideCenterX =
|
||||
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
|
||||
const insideCenterY =
|
||||
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
|
||||
|
||||
if (insideCenterX && insideCenterY) {
|
||||
return "center";
|
||||
}
|
||||
|
||||
const edgeThresholdX = input.width * EDGE_RATIO;
|
||||
const edgeThresholdY = input.height * EDGE_RATIO;
|
||||
if (input.x <= edgeThresholdX) {
|
||||
return "left";
|
||||
}
|
||||
if (input.x >= input.width - edgeThresholdX) {
|
||||
return "right";
|
||||
}
|
||||
if (input.y <= edgeThresholdY) {
|
||||
return "top";
|
||||
}
|
||||
if (input.y >= input.height - edgeThresholdY) {
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
const distances = [
|
||||
{ position: "left", distance: input.x },
|
||||
{ position: "right", distance: input.width - input.x },
|
||||
{ position: "top", distance: input.y },
|
||||
{ position: "bottom", distance: input.height - input.y },
|
||||
] satisfies Array<{ position: Exclude<SplitDropZonePosition, "center">; distance: number }>;
|
||||
distances.sort((left, right) => left.distance - right.distance);
|
||||
return distances[0]?.position ?? "center";
|
||||
}
|
||||
|
||||
function getPreviewOverlayStyle(position: SplitDropZonePosition) {
|
||||
if (position === "left") {
|
||||
return styles.previewLeft;
|
||||
}
|
||||
if (position === "right") {
|
||||
return styles.previewRight;
|
||||
}
|
||||
if (position === "top") {
|
||||
return styles.previewTop;
|
||||
}
|
||||
if (position === "bottom") {
|
||||
return styles.previewBottom;
|
||||
}
|
||||
return styles.previewCenterOverlay;
|
||||
}
|
||||
|
||||
function getPreviewFrameStyle(position: SplitDropZonePosition) {
|
||||
if (position === "left") {
|
||||
return styles.previewLeft;
|
||||
}
|
||||
if (position === "right") {
|
||||
return styles.previewRight;
|
||||
}
|
||||
if (position === "top") {
|
||||
return styles.previewTop;
|
||||
}
|
||||
if (position === "bottom") {
|
||||
return styles.previewBottom;
|
||||
}
|
||||
return styles.previewCenterFrame;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 40,
|
||||
},
|
||||
previewOverlay: {
|
||||
position: "absolute",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
previewFrame: {
|
||||
position: "absolute",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 2,
|
||||
},
|
||||
previewLeft: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "50%",
|
||||
},
|
||||
previewRight: {
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "50%",
|
||||
},
|
||||
previewTop: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
},
|
||||
previewBottom: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: "50%",
|
||||
},
|
||||
previewCenterOverlay: {
|
||||
left: theme.spacing[2],
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
bottom: theme.spacing[2],
|
||||
},
|
||||
previewCenterFrame: {
|
||||
left: theme.spacing[2],
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
bottom: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
"use dom";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DOMProps } from "expo/dom";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
@@ -8,9 +8,29 @@ import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
summarizeTerminalText,
|
||||
terminalDebugLog,
|
||||
} from "../terminal/runtime/terminal-debug";
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
|
||||
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
|
||||
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
|
||||
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
|
||||
const SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING = 8;
|
||||
const SCROLLBAR_HANDLE_OPACITY_VISIBLE = 0.62;
|
||||
const SCROLLBAR_HANDLE_OPACITY_HOVERED = 0.78;
|
||||
const SCROLLBAR_HANDLE_OPACITY_DRAGGING = 0.9;
|
||||
const SCROLLBAR_HANDLE_FADE_DURATION_MS = 220;
|
||||
const SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
|
||||
const SCROLLBAR_HANDLE_TRAVEL_DURATION_MS = 90;
|
||||
const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200;
|
||||
const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110;
|
||||
const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style";
|
||||
|
||||
type ViewportMetrics = {
|
||||
offset: number;
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
};
|
||||
|
||||
function buildXtermThemeKey(theme: ITheme): string {
|
||||
const values: Array<string> = [
|
||||
@@ -74,6 +94,34 @@ declare global {
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function ensureTerminalScrollbarStyle(): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (document.getElementById(WEBKIT_SCROLLBAR_STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = WEBKIT_SCROLLBAR_STYLE_ID;
|
||||
styleElement.textContent = `
|
||||
[data-terminal-scrollbar-root="true"] .xterm-viewport {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
export default function TerminalEmulator({
|
||||
streamKey,
|
||||
initialOutputText,
|
||||
@@ -104,13 +152,32 @@ export default function TerminalEmulator({
|
||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||
const appliedChunkSequenceRef = useRef(0);
|
||||
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
||||
const viewportRef = useRef<HTMLElement | null>(null);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastObservedOffsetRef = useRef<number | null>(null);
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
const [viewportMetrics, setViewportMetrics] = useState<ViewportMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
const [isHandleHovered, setIsHandleHovered] = useState(false);
|
||||
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
|
||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
mountedThemeRef.current = xtermTheme;
|
||||
runtimeRef.current?.setTheme({ theme: xtermTheme });
|
||||
}, [themeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureTerminalScrollbarStyle();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
@@ -287,14 +354,6 @@ export default function TerminalEmulator({
|
||||
}
|
||||
|
||||
if (outputChunkSequence <= appliedChunkSequenceRef.current) {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-component",
|
||||
event: "output:chunk:skip-duplicate",
|
||||
details: {
|
||||
sequence: outputChunkSequence,
|
||||
lastAppliedSequence: appliedChunkSequenceRef.current,
|
||||
},
|
||||
});
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
return;
|
||||
}
|
||||
@@ -307,13 +366,6 @@ export default function TerminalEmulator({
|
||||
appliedChunkSequenceRef.current = outputChunkSequence;
|
||||
|
||||
if (outputChunkText.length === 0) {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-component",
|
||||
event: "output:chunk:clear",
|
||||
details: {
|
||||
sequence: outputChunkSequence,
|
||||
},
|
||||
});
|
||||
runtime.clear({
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
@@ -321,16 +373,6 @@ export default function TerminalEmulator({
|
||||
});
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "emulator-component",
|
||||
event: "output:chunk:write",
|
||||
details: {
|
||||
sequence: outputChunkSequence,
|
||||
replay: outputChunkReplay,
|
||||
length: outputChunkText.length,
|
||||
preview: summarizeTerminalText({ text: outputChunkText, maxChars: 80 }),
|
||||
},
|
||||
});
|
||||
runtime.write({
|
||||
text: outputChunkText,
|
||||
suppressInput: outputChunkReplay,
|
||||
@@ -366,10 +408,206 @@ export default function TerminalEmulator({
|
||||
runtimeRef.current?.resize({ force: true });
|
||||
}, [resizeRequestToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (!viewportElement) {
|
||||
viewportRef.current = null;
|
||||
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
viewportRef.current = viewportElement;
|
||||
|
||||
const updateViewportMetrics = () => {
|
||||
setViewportMetrics({
|
||||
offset: Math.max(0, viewportElement.scrollTop),
|
||||
viewportSize: Math.max(0, viewportElement.clientHeight),
|
||||
contentSize: Math.max(0, viewportElement.scrollHeight),
|
||||
});
|
||||
};
|
||||
|
||||
updateViewportMetrics();
|
||||
|
||||
const handleViewportScroll = () => {
|
||||
updateViewportMetrics();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateViewportMetrics();
|
||||
});
|
||||
resizeObserver.observe(viewportElement);
|
||||
const scrollAreaElement = host.querySelector<HTMLElement>(".xterm-scroll-area");
|
||||
if (scrollAreaElement) {
|
||||
resizeObserver.observe(scrollAreaElement);
|
||||
}
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
updateViewportMetrics();
|
||||
});
|
||||
mutationObserver.observe(host, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
});
|
||||
|
||||
viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
viewportElement.removeEventListener("scroll", handleViewportScroll);
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
if (viewportRef.current === viewportElement) {
|
||||
viewportRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxScrollOffset = Math.max(
|
||||
0,
|
||||
viewportMetrics.contentSize - viewportMetrics.viewportSize
|
||||
);
|
||||
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
|
||||
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
|
||||
setIsScrollVisible(false);
|
||||
setIsScrollActive(false);
|
||||
lastObservedOffsetRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOffset = lastObservedOffsetRef.current;
|
||||
lastObservedOffsetRef.current = normalizedOffset;
|
||||
if (previousOffset === null || Math.abs(previousOffset - normalizedOffset) <= 0.5) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScrollVisible(true);
|
||||
if (scrollVisibilityTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollVisibilityTimeoutRef.current);
|
||||
}
|
||||
scrollVisibilityTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrollVisible(false);
|
||||
scrollVisibilityTimeoutRef.current = null;
|
||||
}, SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS);
|
||||
|
||||
setIsScrollActive(true);
|
||||
if (scrollActiveTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollActiveTimeoutRef.current);
|
||||
}
|
||||
scrollActiveTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrollActive(false);
|
||||
scrollActiveTimeoutRef.current = null;
|
||||
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
|
||||
}, [
|
||||
viewportMetrics.contentSize,
|
||||
viewportMetrics.offset,
|
||||
viewportMetrics.viewportSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollVisibilityTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollVisibilityTimeoutRef.current);
|
||||
}
|
||||
if (scrollActiveTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollActiveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollbarGeometry = useMemo(
|
||||
() =>
|
||||
computeVerticalScrollbarGeometry({
|
||||
viewportSize: viewportMetrics.viewportSize,
|
||||
contentSize: viewportMetrics.contentSize,
|
||||
offset: viewportMetrics.offset,
|
||||
}),
|
||||
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingScrollbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const dragDelta = event.clientY - dragStartClientYRef.current;
|
||||
const nextOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: dragStartOffsetRef.current,
|
||||
dragDelta,
|
||||
maxScrollOffset: scrollbarGeometry.maxScrollOffset,
|
||||
maxHandleOffset: scrollbarGeometry.maxHandleOffset,
|
||||
});
|
||||
const viewportElement = viewportRef.current;
|
||||
if (!viewportElement) {
|
||||
return;
|
||||
}
|
||||
viewportElement.scrollTop = nextOffset;
|
||||
setViewportMetrics({
|
||||
offset: nextOffset,
|
||||
viewportSize: Math.max(0, viewportElement.clientHeight),
|
||||
contentSize: Math.max(0, viewportElement.scrollHeight),
|
||||
});
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
setIsDraggingScrollbar(false);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopDragging);
|
||||
window.addEventListener("pointercancel", stopDragging);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
};
|
||||
}, [
|
||||
isDraggingScrollbar,
|
||||
scrollbarGeometry.maxHandleOffset,
|
||||
scrollbarGeometry.maxScrollOffset,
|
||||
]);
|
||||
|
||||
const handleVisible =
|
||||
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
|
||||
const handleOpacity = isDraggingScrollbar
|
||||
? SCROLLBAR_HANDLE_OPACITY_DRAGGING
|
||||
: isHandleHovered
|
||||
? SCROLLBAR_HANDLE_OPACITY_HOVERED
|
||||
: isScrollVisible
|
||||
? SCROLLBAR_HANDLE_OPACITY_VISIBLE
|
||||
: 0;
|
||||
const handleWidth =
|
||||
isDraggingScrollbar || isHandleHovered
|
||||
? SCROLLBAR_HANDLE_WIDTH_ACTIVE
|
||||
: SCROLLBAR_HANDLE_WIDTH_IDLE;
|
||||
const thumbRegionOffset = Math.max(
|
||||
0,
|
||||
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING
|
||||
);
|
||||
const thumbRegionHeight = Math.min(
|
||||
viewportMetrics.viewportSize - thumbRegionOffset,
|
||||
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2
|
||||
);
|
||||
const handleInsetTop = Math.max(
|
||||
0,
|
||||
(thumbRegionHeight - scrollbarGeometry.handleSize) / 2
|
||||
);
|
||||
const handleTravelDurationMs =
|
||||
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid={testId}
|
||||
data-terminal-scrollbar-root="true"
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
@@ -383,10 +621,6 @@ export default function TerminalEmulator({
|
||||
touchAction: "pan-y",
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-component",
|
||||
event: "surface:pointer-down-focus",
|
||||
});
|
||||
runtimeRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
@@ -400,8 +634,82 @@ export default function TerminalEmulator({
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
/>
|
||||
{scrollbarGeometry.isVisible ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: -3,
|
||||
width: SCROLLBAR_HANDLE_GRAB_WIDTH,
|
||||
height: thumbRegionHeight,
|
||||
transform: `translateY(${thumbRegionOffset}px)`,
|
||||
cursor: isDraggingScrollbar ? "grabbing" : "grab",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
transitionProperty: "transform",
|
||||
transitionDuration: `${handleTravelDurationMs}ms`,
|
||||
transitionTimingFunction: "linear",
|
||||
pointerEvents: handleVisible ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragStartOffsetRef.current = clamp(
|
||||
viewportMetrics.offset,
|
||||
0,
|
||||
scrollbarGeometry.maxScrollOffset
|
||||
);
|
||||
dragStartClientYRef.current = event.clientY;
|
||||
setIsDraggingScrollbar(true);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (!isScrollVisible && !isDraggingScrollbar) {
|
||||
return;
|
||||
}
|
||||
setIsHandleHovered(true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setIsHandleHovered(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginTop: handleInsetTop,
|
||||
height: scrollbarGeometry.handleSize,
|
||||
width: handleWidth,
|
||||
borderRadius: 999,
|
||||
alignSelf: "center",
|
||||
backgroundColor: "rgba(113, 113, 122, 1)",
|
||||
opacity: handleOpacity,
|
||||
transitionProperty: "opacity, width, background-color",
|
||||
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
|
||||
transitionTimingFunction:
|
||||
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,10 +35,6 @@ import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "@/terminal/runtime/terminal-stream-controller";
|
||||
import {
|
||||
summarizeTerminalText,
|
||||
terminalDebugLog,
|
||||
} from "@/terminal/runtime/terminal-debug";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
@@ -366,7 +362,7 @@ export function TerminalPane({
|
||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
if (!client || !isConnected || !isScreenFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -716,17 +712,6 @@ export function TerminalPane({
|
||||
meta: input.meta,
|
||||
},
|
||||
};
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:key:send",
|
||||
details: {
|
||||
key: normalizedKey,
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
alt: input.alt,
|
||||
activeStreamId: getCurrentActiveStreamId(),
|
||||
},
|
||||
});
|
||||
if (!dispatchTerminalInputEntry(pendingEntry)) {
|
||||
enqueuePendingTerminalInput(pendingEntry);
|
||||
}
|
||||
@@ -745,16 +730,6 @@ export function TerminalPane({
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentStreamId = getCurrentActiveStreamId();
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:data:received",
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (hasPendingTerminalModifiers(modifiers)) {
|
||||
const pendingResolution = resolvePendingModifierDataInput({
|
||||
@@ -788,15 +763,6 @@ export function TerminalPane({
|
||||
});
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:data:send",
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
const pendingEntry: PendingTerminalInput = {
|
||||
type: "data",
|
||||
data,
|
||||
@@ -835,15 +801,6 @@ export function TerminalPane({
|
||||
return;
|
||||
}
|
||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "display:resize:send",
|
||||
details: {
|
||||
terminalId: selectedTerminalId,
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
},
|
||||
});
|
||||
client.sendTerminalInput(selectedTerminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
@@ -871,13 +828,6 @@ export function TerminalPane({
|
||||
}, [clearPendingModifiers]);
|
||||
|
||||
const handleOutputChunkConsumed = useCallback((sequence: number) => {
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "output:chunk:consumed",
|
||||
details: {
|
||||
sequence,
|
||||
},
|
||||
});
|
||||
outputSession.consume({ sequence });
|
||||
}, [outputSession]);
|
||||
|
||||
@@ -1040,7 +990,7 @@ export function TerminalPane({
|
||||
) : null}
|
||||
|
||||
<View style={styles.outputContainer}>
|
||||
{selectedTerminal ? (
|
||||
{selectedTerminal && isScreenFocused ? (
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
dom={{
|
||||
@@ -1084,13 +1034,15 @@ export function TerminalPane({
|
||||
resizeRequestToken={resizeRequestToken}
|
||||
/>
|
||||
</View>
|
||||
) : selectedTerminal ? (
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
) : (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.stateText}>No terminal selected</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isAttaching ? (
|
||||
{isAttaching && isScreenFocused ? (
|
||||
<View
|
||||
style={styles.attachOverlay}
|
||||
pointerEvents="none"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildLineDiff,
|
||||
parseUnifiedDiff,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
|
||||
@@ -20,6 +21,7 @@ interface ToolCallDetailsContentProps {
|
||||
errorText?: string;
|
||||
maxHeight?: number;
|
||||
fillAvailableHeight?: boolean;
|
||||
showLoadingSkeleton?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({
|
||||
@@ -27,6 +29,7 @@ export function ToolCallDetailsContent({
|
||||
errorText,
|
||||
maxHeight,
|
||||
fillAvailableHeight = false,
|
||||
showLoadingSkeleton = false,
|
||||
}: ToolCallDetailsContentProps) {
|
||||
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
||||
|
||||
@@ -240,9 +243,79 @@ export function ToolCallDetailsContent({
|
||||
);
|
||||
}
|
||||
} else if (detail?.type === "search") {
|
||||
const searchSections: ReactNode[] = [];
|
||||
if (detail.query) {
|
||||
searchSections.push(
|
||||
<View key="search-query" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.query}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.content) {
|
||||
searchSections.push(
|
||||
<View key="search-content" style={styles.section}>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText}>{detail.content}</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.filePaths && detail.filePaths.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-files" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.filePaths.join("\n")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.webResults && detail.webResults.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-web-results" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.annotations && detail.annotations.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-annotations" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.annotations.join("\n\n")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
sections.push(...searchSections);
|
||||
} else if (detail?.type === "fetch") {
|
||||
sections.push(
|
||||
<View key="search" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.query}</Text>
|
||||
<View
|
||||
key="fetch"
|
||||
style={[styles.section, shouldFill && styles.fillHeight]}
|
||||
>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
} else if (detail?.type === "plain_text") {
|
||||
@@ -269,7 +342,11 @@ export function ToolCallDetailsContent({
|
||||
const sectionsFromTopLevel = [
|
||||
{ title: "Input", value: detail.input },
|
||||
{ title: "Output", value: detail.output },
|
||||
].filter((entry) => entry.value !== null && entry.value !== undefined);
|
||||
].filter((entry) => hasMeaningfulToolCallDetail({
|
||||
type: "unknown",
|
||||
input: entry.value ?? null,
|
||||
output: null,
|
||||
}));
|
||||
|
||||
for (const section of sectionsFromTopLevel) {
|
||||
let value = "";
|
||||
@@ -327,6 +404,20 @@ export function ToolCallDetailsContent({
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
if (showLoadingSkeleton) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.loadingContainer,
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
]}
|
||||
>
|
||||
<View style={styles.loadingLineWide} />
|
||||
<View style={styles.loadingLineMedium} />
|
||||
<View style={styles.loadingLineShort} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text style={styles.emptyStateText}>No additional details available</Text>
|
||||
);
|
||||
@@ -471,5 +562,27 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
loadingContainer: {
|
||||
gap: theme.spacing[2],
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
loadingLineWide: {
|
||||
height: 12,
|
||||
width: "100%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
loadingLineMedium: {
|
||||
height: 12,
|
||||
width: "72%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
loadingLineShort: {
|
||||
height: 12,
|
||||
width: "48%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ToolCallSheetData = {
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
errorText?: string;
|
||||
showLoadingSkeleton?: boolean;
|
||||
};
|
||||
|
||||
interface ToolCallSheetContextValue {
|
||||
@@ -131,7 +132,7 @@ interface ToolCallSheetContentProps {
|
||||
}
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { toolName, displayName, detail, errorText } = data;
|
||||
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
|
||||
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
|
||||
@@ -159,6 +160,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
detail={detail}
|
||||
errorText={errorText}
|
||||
fillAvailableHeight
|
||||
showLoadingSkeleton={showLoadingSkeleton}
|
||||
/>
|
||||
</BottomSheetScrollView>
|
||||
</View>
|
||||
|
||||
@@ -93,7 +93,7 @@ function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
export interface SearchInputProps {
|
||||
placeholder: string
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
@@ -101,7 +101,7 @@ interface SearchInputProps {
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
export function SearchInput({
|
||||
placeholder,
|
||||
value,
|
||||
onChangeText,
|
||||
@@ -144,6 +144,7 @@ export interface ComboboxItemProps {
|
||||
label: string
|
||||
description?: string
|
||||
kind?: 'directory' | 'file'
|
||||
leadingSlot?: ReactNode
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
@@ -154,12 +155,26 @@ export function ComboboxItem({
|
||||
label,
|
||||
description,
|
||||
kind,
|
||||
leadingSlot,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
const { theme } = useUnistyles()
|
||||
|
||||
const leadingContent = leadingSlot ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>
|
||||
) : kind === 'directory' || kind === 'file' ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
{kind === 'directory' ? (
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
@@ -171,15 +186,7 @@ export function ComboboxItem({
|
||||
active && styles.comboboxItemActive,
|
||||
]}
|
||||
>
|
||||
{kind === 'directory' || kind === 'file' ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
{kind === 'directory' ? (
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{leadingContent}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
{label}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const FOOTER_HEIGHT = 75;
|
||||
// This ensures both headers have the same visual height
|
||||
export const HEADER_INNER_HEIGHT = 48;
|
||||
export const HEADER_INNER_HEIGHT_MOBILE = 56;
|
||||
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
|
||||
export const HEADER_TOP_PADDING_MOBILE = 8;
|
||||
|
||||
// Max width for chat content (stream view, input area, new agent form)
|
||||
|
||||
180
packages/app/src/desktop/updates/update-banner.tsx
Normal file
180
packages/app/src/desktop/updates/update-banner.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
26
packages/app/src/hooks/use-workspace-navigation.ts
Normal file
26
packages/app/src/hooks/use-workspace-navigation.ts
Normal 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);
|
||||
},
|
||||
[]
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,17 @@ export type KeyboardActionId =
|
||||
| "workspace.tab.close.current"
|
||||
| "workspace.tab.navigate.index"
|
||||
| "workspace.tab.navigate.relative"
|
||||
| "workspace.pane.split.right"
|
||||
| "workspace.pane.split.down"
|
||||
| "workspace.pane.focus.left"
|
||||
| "workspace.pane.focus.right"
|
||||
| "workspace.pane.focus.up"
|
||||
| "workspace.pane.focus.down"
|
||||
| "workspace.pane.move-tab.left"
|
||||
| "workspace.pane.move-tab.right"
|
||||
| "workspace.pane.move-tab.up"
|
||||
| "workspace.pane.move-tab.down"
|
||||
| "workspace.pane.close"
|
||||
| "workspace.navigate.index"
|
||||
| "workspace.navigate.relative"
|
||||
| "sidebar.toggle.left"
|
||||
|
||||
@@ -9,12 +9,44 @@ export type KeyboardActionId =
|
||||
| "message-input.dictation-toggle"
|
||||
| "message-input.dictation-cancel"
|
||||
| "message-input.voice-toggle"
|
||||
| "message-input.voice-mute-toggle";
|
||||
| "message-input.voice-mute-toggle"
|
||||
| "workspace.tab.new"
|
||||
| "workspace.tab.close-current"
|
||||
| "workspace.tab.navigate-index"
|
||||
| "workspace.tab.navigate-relative"
|
||||
| "workspace.pane.split.right"
|
||||
| "workspace.pane.split.down"
|
||||
| "workspace.pane.focus.left"
|
||||
| "workspace.pane.focus.right"
|
||||
| "workspace.pane.focus.up"
|
||||
| "workspace.pane.focus.down"
|
||||
| "workspace.pane.move-tab.left"
|
||||
| "workspace.pane.move-tab.right"
|
||||
| "workspace.pane.move-tab.up"
|
||||
| "workspace.pane.move-tab.down"
|
||||
| "workspace.pane.close";
|
||||
|
||||
export type KeyboardActionDefinition = {
|
||||
id: KeyboardActionId;
|
||||
scope: KeyboardActionScope;
|
||||
};
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-cancel"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
||||
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
|
||||
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.focus.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.focus.up"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.focus.down"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.left"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope };
|
||||
|
||||
export type KeyboardActionHandler = {
|
||||
handlerId: string;
|
||||
|
||||
@@ -169,6 +169,42 @@ describe("keyboard-shortcuts", () => {
|
||||
context: { isMac: true, isTauri: true },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Backslash to split pane right on macOS",
|
||||
event: { key: "\\", code: "Backslash", metaKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.split.right",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Shift+Backslash to split pane down on macOS",
|
||||
event: { key: "|", code: "Backslash", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.split.down",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Shift+ArrowRight to focus pane right on macOS",
|
||||
event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.focus.right",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Shift+Alt+ArrowDown to move tab down on macOS",
|
||||
event: {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
altKey: true,
|
||||
},
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.move-tab.down",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+Shift+W to close pane on macOS",
|
||||
event: { key: "W", code: "KeyW", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.pane.close",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+B sidebar toggle on macOS",
|
||||
event: { key: "b", code: "KeyB", metaKey: true },
|
||||
@@ -242,6 +278,11 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "d", code: "KeyD", metaKey: true },
|
||||
context: { isMac: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not bind pane shortcuts on non-mac platforms",
|
||||
event: { key: "\\", code: "Backslash", ctrlKey: true },
|
||||
context: { isMac: false },
|
||||
},
|
||||
{
|
||||
name: "keeps space typing available in message input",
|
||||
event: { key: " ", code: "Space" },
|
||||
@@ -278,6 +319,8 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
|
||||
"workspace-tab-close-current": ["alt", "shift", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -289,6 +332,8 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-close-current": ["mod", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -71,6 +71,10 @@ function isMod(event: KeyboardEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
function isMacCommand(event: KeyboardEvent): boolean {
|
||||
return event.metaKey && !event.ctrlKey;
|
||||
}
|
||||
|
||||
function parseDigit(event: KeyboardEvent): number | null {
|
||||
const code = event.code ?? "";
|
||||
if (code.startsWith("Digit")) {
|
||||
@@ -373,6 +377,204 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
keys: ["alt", "shift", "]"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-split-right-cmd-backslash",
|
||||
action: "workspace.pane.split.right",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.code === "Backslash",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-split-right",
|
||||
section: "global",
|
||||
label: "Split pane right",
|
||||
keys: ["mod", "\\"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-split-down-cmd-shift-backslash",
|
||||
action: "workspace.pane.split.down",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "Backslash",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-split-down",
|
||||
section: "global",
|
||||
label: "Split pane down",
|
||||
keys: ["mod", "shift", "\\"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-focus-left-cmd-shift-left",
|
||||
action: "workspace.pane.focus.left",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowLeft",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-focus-left",
|
||||
section: "global",
|
||||
label: "Focus pane left",
|
||||
keys: ["mod", "shift", "Left"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-focus-right-cmd-shift-right",
|
||||
action: "workspace.pane.focus.right",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowRight",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-focus-right",
|
||||
section: "global",
|
||||
label: "Focus pane right",
|
||||
keys: ["mod", "shift", "Right"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-focus-up-cmd-shift-up",
|
||||
action: "workspace.pane.focus.up",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowUp",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-focus-up",
|
||||
section: "global",
|
||||
label: "Focus pane up",
|
||||
keys: ["mod", "shift", "Up"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-focus-down-cmd-shift-down",
|
||||
action: "workspace.pane.focus.down",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowDown",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-focus-down",
|
||||
section: "global",
|
||||
label: "Focus pane down",
|
||||
keys: ["mod", "shift", "Down"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-move-tab-left-cmd-shift-alt-left",
|
||||
action: "workspace.pane.move-tab.left",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowLeft",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-left",
|
||||
section: "global",
|
||||
label: "Move tab left",
|
||||
keys: ["mod", "shift", "alt", "Left"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-move-tab-right-cmd-shift-alt-right",
|
||||
action: "workspace.pane.move-tab.right",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowRight",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-right",
|
||||
section: "global",
|
||||
label: "Move tab right",
|
||||
keys: ["mod", "shift", "alt", "Right"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-move-tab-up-cmd-shift-alt-up",
|
||||
action: "workspace.pane.move-tab.up",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowUp",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-up",
|
||||
section: "global",
|
||||
label: "Move tab up",
|
||||
keys: ["mod", "shift", "alt", "Up"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-move-tab-down-cmd-shift-alt-down",
|
||||
action: "workspace.pane.move-tab.down",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "ArrowDown",
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-down",
|
||||
section: "global",
|
||||
label: "Move tab down",
|
||||
keys: ["mod", "shift", "alt", "Down"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-pane-close-cmd-shift-w",
|
||||
action: "workspace.pane.close",
|
||||
matches: (event) =>
|
||||
isMacCommand(event) &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
||||
when: (context) =>
|
||||
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-pane-close",
|
||||
section: "global",
|
||||
label: "Close pane",
|
||||
keys: ["mod", "shift", "W"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "command-center-toggle",
|
||||
action: "command-center.toggle",
|
||||
|
||||
85
packages/app/src/panels/agent-panel.tsx
Normal file
85
packages/app/src/panels/agent-panel.tsx
Normal 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,
|
||||
};
|
||||
56
packages/app/src/panels/draft-panel.tsx
Normal file
56
packages/app/src/panels/draft-panel.tsx
Normal 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,
|
||||
};
|
||||
34
packages/app/src/panels/file-panel.tsx
Normal file
34
packages/app/src/panels/file-panel.tsx
Normal 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,
|
||||
};
|
||||
32
packages/app/src/panels/pane-context.tsx
Normal file
32
packages/app/src/panels/pane-context.tsx
Normal 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;
|
||||
}
|
||||
50
packages/app/src/panels/panel-registry.ts
Normal file
50
packages/app/src/panels/panel-registry.ts
Normal 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);
|
||||
}
|
||||
18
packages/app/src/panels/register-panels.ts
Normal file
18
packages/app/src/panels/register-panels.ts
Normal 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;
|
||||
}
|
||||
80
packages/app/src/panels/terminal-panel.tsx
Normal file
80
packages/app/src/panels/terminal-panel.tsx
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -10,11 +10,7 @@ function makeAgentTab(id: string): WorkspaceTabDescriptor {
|
||||
key: `agent_${id}`,
|
||||
tabId: `agent_${id}`,
|
||||
kind: "agent",
|
||||
agentId: id,
|
||||
provider: "codex",
|
||||
label: `Agent ${id}`,
|
||||
subtitle: "",
|
||||
titleState: "ready",
|
||||
target: { kind: "agent", agentId: id },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,9 +19,7 @@ function makeTerminalTab(id: string): WorkspaceTabDescriptor {
|
||||
key: `terminal_${id}`,
|
||||
tabId: `terminal_${id}`,
|
||||
kind: "terminal",
|
||||
terminalId: id,
|
||||
label: `Terminal ${id}`,
|
||||
subtitle: "",
|
||||
target: { kind: "terminal", terminalId: id },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,9 +28,7 @@ function makeFileTab(path: string): WorkspaceTabDescriptor {
|
||||
key: `file_${path}`,
|
||||
tabId: `file_${path}`,
|
||||
kind: "file",
|
||||
filePath: path,
|
||||
label: path.split("/").pop() ?? path,
|
||||
subtitle: path,
|
||||
target: { kind: "file", path },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
|
||||
};
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (tab.kind === "agent") {
|
||||
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId });
|
||||
if (tab.target.kind === "agent") {
|
||||
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId });
|
||||
continue;
|
||||
}
|
||||
if (tab.kind === "terminal") {
|
||||
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId });
|
||||
if (tab.target.kind === "terminal") {
|
||||
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId });
|
||||
continue;
|
||||
}
|
||||
groups.otherTabs.push({ tabId: tab.tabId });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { Plus, X } from "lucide-react-native";
|
||||
import { ActivityIndicator, Platform, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import {
|
||||
@@ -12,31 +12,42 @@ import {
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||
import {
|
||||
deriveWorkspaceTabPresentation,
|
||||
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",
|
||||
|
||||
@@ -57,8 +57,11 @@ export function WorkspaceDraftAgentTab({
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
setProviderAndModelFromUser,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
initialServerId: serverId,
|
||||
@@ -246,6 +249,9 @@ export function WorkspaceDraftAgentTab({
|
||||
selectedModel,
|
||||
onSelectModel: setModelFromUser,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ComponentType } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { PaneProvider, type PaneContextValue } from "@/panels/pane-context";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export interface WorkspacePaneContentModel {
|
||||
key: string;
|
||||
Component: ComponentType;
|
||||
paneContextValue: PaneContextValue;
|
||||
}
|
||||
|
||||
export interface BuildWorkspacePaneContentModelInput {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onCloseCurrentTab: () => void;
|
||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onOpenWorkspaceFile: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function buildWorkspacePaneContentModel({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
onOpenTab,
|
||||
onCloseCurrentTab,
|
||||
onRetargetCurrentTab,
|
||||
onOpenWorkspaceFile,
|
||||
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
return {
|
||||
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
|
||||
Component: registration.component,
|
||||
paneContextValue: {
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: tab.tabId,
|
||||
target: tab.target,
|
||||
openTab: onOpenTab,
|
||||
closeCurrentTab: onCloseCurrentTab,
|
||||
retargetCurrentTab: onRetargetCurrentTab,
|
||||
openFileInWorkspace: onOpenWorkspaceFile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspacePaneContentProps {
|
||||
content: WorkspacePaneContentModel;
|
||||
}
|
||||
|
||||
export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) {
|
||||
const { Component, key, paneContextValue } = content;
|
||||
|
||||
return (
|
||||
<PaneProvider value={paneContextValue}>
|
||||
<Component key={key} />
|
||||
</PaneProvider>
|
||||
);
|
||||
}
|
||||
104
packages/app/src/screens/workspace/workspace-pane-state.test.ts
Normal file
104
packages/app/src/screens/workspace/workspace-pane-state.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
199
packages/app/src/screens/workspace/workspace-pane-state.ts
Normal file
199
packages/app/src/screens/workspace/workspace-pane-state.ts
Normal 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
@@ -14,7 +14,7 @@ const metrics = {
|
||||
};
|
||||
|
||||
describe("computeWorkspaceTabLayout", () => {
|
||||
it("keeps fit-content tab widths when there is extra horizontal space", () => {
|
||||
it("caps equal-width tabs at the ideal width when there is extra horizontal space", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 1200,
|
||||
tabLabelLengths: [8, 10, 7],
|
||||
@@ -25,10 +25,10 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
expect(result.items.map((item) => item.width)).toEqual([126, 140, 119]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([200, 200, 200]);
|
||||
});
|
||||
|
||||
it("shrinks tab widths proportionally before hiding labels", () => {
|
||||
it("shrinks equal-width tabs proportionally to fit the pane", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 520,
|
||||
tabLabelLengths: [24, 12, 8],
|
||||
@@ -37,10 +37,27 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([151, 121, 103]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([125, 125, 125]);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the split width for evenly sized tabs when space is available", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 743,
|
||||
tabLabelLengths: [8, 8, 8, 8],
|
||||
metrics: {
|
||||
...metrics,
|
||||
actionsReservedWidth: 44,
|
||||
rowPaddingHorizontal: 0,
|
||||
tabGap: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([175, 175, 175, 175]);
|
||||
});
|
||||
|
||||
it("collapses to icon-only before allowing horizontal scroll fallback", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 388,
|
||||
|
||||
@@ -38,14 +38,6 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function sum(values: number[]): number {
|
||||
let total = 0;
|
||||
for (const value of values) {
|
||||
total += value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function computeWorkspaceTabLayout(
|
||||
input: WorkspaceTabLayoutInput
|
||||
): WorkspaceTabLayoutResult {
|
||||
@@ -67,33 +59,13 @@ export function computeWorkspaceTabLayout(
|
||||
const availableTabsWidth = Math.max(0, availableWidth - rowOverhead);
|
||||
const iconOnlyTabWidth =
|
||||
input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2 + input.metrics.closeButtonWidth;
|
||||
const labelSafetyPadding = 10;
|
||||
const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
|
||||
const desiredTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||
const labelLength = Math.max(rawLength, 1);
|
||||
const estimatedWidth = iconOnlyTabWidth + estimateLabelWidth(labelLength) + labelSafetyPadding;
|
||||
return clamp(estimatedWidth, iconOnlyTabWidth, input.metrics.maxTabWidth);
|
||||
});
|
||||
|
||||
const desiredTotalTabsWidth = sum(desiredTabWidths);
|
||||
const iconOnlyTotalTabsWidth = iconOnlyTabWidth * tabCount;
|
||||
const requiresHorizontalScrollFallback = availableTabsWidth < iconOnlyTotalTabsWidth;
|
||||
|
||||
let resolvedWidths: number[];
|
||||
if (desiredTotalTabsWidth <= availableTabsWidth) {
|
||||
// Fit-content baseline: do not stretch tabs when there is spare space.
|
||||
resolvedWidths = desiredTabWidths;
|
||||
} else if (availableTabsWidth <= iconOnlyTotalTabsWidth) {
|
||||
resolvedWidths = new Array(tabCount).fill(iconOnlyTabWidth);
|
||||
} else {
|
||||
const desiredExpandableWidth = desiredTotalTabsWidth - iconOnlyTotalTabsWidth;
|
||||
const availableExpandableWidth = availableTabsWidth - iconOnlyTotalTabsWidth;
|
||||
const proportionalRatio =
|
||||
desiredExpandableWidth > 0 ? availableExpandableWidth / desiredExpandableWidth : 0;
|
||||
resolvedWidths = desiredTabWidths.map(
|
||||
(desiredWidth) => iconOnlyTabWidth + (desiredWidth - iconOnlyTabWidth) * proportionalRatio
|
||||
);
|
||||
}
|
||||
const resolvedWidths = new Array(tabCount).fill(
|
||||
requiresHorizontalScrollFallback
|
||||
? iconOnlyTabWidth
|
||||
: clamp(availableTabsWidth / tabCount, iconOnlyTabWidth, input.metrics.maxTabWidth)
|
||||
);
|
||||
|
||||
const roundedWidths = resolvedWidths.map((width) =>
|
||||
Math.round(clamp(width, iconOnlyTabWidth, input.metrics.maxTabWidth))
|
||||
|
||||
@@ -7,11 +7,7 @@ function createAgentTab(): WorkspaceTabDescriptor {
|
||||
key: "agent_123",
|
||||
tabId: "agent_123",
|
||||
kind: "agent",
|
||||
agentId: "agent-123",
|
||||
provider: "codex",
|
||||
label: "Agent 123",
|
||||
subtitle: "/repo",
|
||||
titleState: "ready",
|
||||
target: { kind: "agent", agentId: "agent-123" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,9 +80,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
key: "draft_123",
|
||||
tabId: "draft_123",
|
||||
kind: "draft",
|
||||
draftId: "draft_123",
|
||||
label: "New agent",
|
||||
subtitle: "/repo",
|
||||
target: { kind: "draft", draftId: "draft_123" },
|
||||
},
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
|
||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||
|
||||
@@ -31,6 +32,24 @@ interface BuildWorkspaceTabMenuEntriesInput {
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface BuildWorkspaceDesktopTabActionsInput {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
index: number;
|
||||
tabCount: number;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface WorkspaceDesktopTabActions {
|
||||
contextMenuTestId: string;
|
||||
menuEntries: WorkspaceTabMenuEntry[];
|
||||
closeButtonTestId: string;
|
||||
}
|
||||
|
||||
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "Close tabs above" : "Close to the left";
|
||||
}
|
||||
@@ -47,6 +66,19 @@ function buildCloseAfterTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "close-below" : "close-right";
|
||||
}
|
||||
|
||||
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "agent") {
|
||||
return `workspace-agent-close-${tab.target.agentId}`;
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
return `workspace-terminal-close-${tab.target.terminalId}`;
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return `workspace-draft-close-${tab.target.draftId}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabMenuEntries(
|
||||
input: BuildWorkspaceTabMenuEntriesInput
|
||||
): WorkspaceTabMenuEntry[] {
|
||||
@@ -68,14 +100,15 @@ export function buildWorkspaceTabMenuEntries(
|
||||
const isOnlyTab = tabCount <= 1;
|
||||
const entries: WorkspaceTabMenuEntry[] = [];
|
||||
|
||||
if (tab.kind === "agent") {
|
||||
if (tab.target.kind === "agent") {
|
||||
const { agentId } = tab.target;
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "copy-resume-command",
|
||||
label: "Copy resume command",
|
||||
testID: `${menuTestIDBase}-copy-resume-command`,
|
||||
onSelect: () => {
|
||||
void onCopyResumeCommand(tab.agentId);
|
||||
void onCopyResumeCommand(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
@@ -84,7 +117,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
label: "Copy agent id",
|
||||
testID: `${menuTestIDBase}-copy-agent-id`,
|
||||
onSelect: () => {
|
||||
void onCopyAgentId(tab.agentId);
|
||||
void onCopyAgentId(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
@@ -135,3 +168,26 @@ export function buildWorkspaceTabMenuEntries(
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildWorkspaceDesktopTabActions(
|
||||
input: BuildWorkspaceDesktopTabActionsInput
|
||||
): WorkspaceDesktopTabActions {
|
||||
const contextMenuTestId = `workspace-tab-context-${input.tab.key}`;
|
||||
return {
|
||||
contextMenuTestId,
|
||||
menuEntries: buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab: input.tab,
|
||||
index: input.index,
|
||||
tabCount: input.tabCount,
|
||||
menuTestIDBase: contextMenuTestId,
|
||||
onCopyResumeCommand: input.onCopyResumeCommand,
|
||||
onCopyAgentId: input.onCopyAgentId,
|
||||
onCloseTab: input.onCloseTab,
|
||||
onCloseTabsBefore: input.onCloseTabsToLeft,
|
||||
onCloseTabsAfter: input.onCloseTabsToRight,
|
||||
onCloseOtherTabs: input.onCloseOtherTabs,
|
||||
}),
|
||||
closeButtonTestId: getCloseButtonTestId(input.tab),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,85 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string;
|
||||
provider?: Agent["provider"];
|
||||
title?: string | null;
|
||||
createdAt?: Date;
|
||||
lastActivityAt?: Date;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: Agent["attentionReason"];
|
||||
}): Agent {
|
||||
const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z");
|
||||
const lastActivityAt = input.lastActivityAt ?? createdAt;
|
||||
return {
|
||||
serverId: "srv",
|
||||
id: input.id,
|
||||
provider: input.provider ?? "codex",
|
||||
status: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: input.provider ?? "codex",
|
||||
sessionId: null,
|
||||
},
|
||||
title: input.title ?? null,
|
||||
cwd: "/repo/worktree",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
labels: {},
|
||||
requiresAttention: input.requiresAttention ?? false,
|
||||
attentionReason: input.attentionReason ?? null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveWorkspaceTabModel", () => {
|
||||
it("derives agent and terminal tabs from domain state, not UI membership", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [
|
||||
makeAgent({ id: "agent-a", title: "Build API" }),
|
||||
makeAgent({ id: "agent-b", title: "" }),
|
||||
],
|
||||
terminals: [{ id: "term-1", name: "shell" }],
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
|
||||
],
|
||||
tabOrder: [],
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
"agent_agent-a",
|
||||
"agent_agent-b",
|
||||
"terminal_term-1",
|
||||
]);
|
||||
const firstAgent = model.tabs[0]?.descriptor;
|
||||
const secondAgent = model.tabs[1]?.descriptor;
|
||||
expect(firstAgent?.kind === "agent" ? firstAgent.titleState : null).toBe("ready");
|
||||
expect(secondAgent?.kind === "agent" ? secondAgent.titleState : null).toBe("loading");
|
||||
expect(secondAgent?.label).toBe("");
|
||||
});
|
||||
|
||||
it("keeps draft and file tabs as explicit UI tabs", () => {
|
||||
it("keeps normalized tabs in stored order and preserves targets", () => {
|
||||
const uiTabs: WorkspaceTab[] = [
|
||||
{
|
||||
tabId: "draft_123",
|
||||
@@ -91,32 +15,37 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
target: { kind: "file", path: "/repo/worktree/README.md" },
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
tabId: "agent_agent-a",
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
...uiTabs,
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 3 },
|
||||
],
|
||||
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
|
||||
tabs: [uiTabs[0]!, uiTabs[2]!, uiTabs[1]!],
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]);
|
||||
expect(model.tabs[0]?.descriptor.label).toBe("New Agent");
|
||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
"draft_123",
|
||||
"agent_agent-a",
|
||||
"file_/repo/worktree/README.md",
|
||||
]);
|
||||
expect(model.tabs[0]?.descriptor.target).toEqual({ kind: "draft", draftId: "draft_123" });
|
||||
expect(model.tabs[1]?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" });
|
||||
expect(model.tabs[2]?.descriptor.target).toEqual({
|
||||
kind: "file",
|
||||
path: "/repo/worktree/README.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies stored order and appends newly-derived tabs deterministically", () => {
|
||||
it("applies stored order and appends unordered tabs deterministically", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||
terminals: [{ id: "term-1", name: "zsh" }],
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
],
|
||||
tabOrder: ["terminal_term-1", "agent_agent-b"],
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
@@ -128,13 +57,10 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
|
||||
it("uses focused tab when present, otherwise falls back to first tab", () => {
|
||||
const base: Parameters<typeof deriveWorkspaceTabModel>[0] = {
|
||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
],
|
||||
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
||||
};
|
||||
|
||||
expect(
|
||||
@@ -144,81 +70,50 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
}).activeTabId
|
||||
).toBe("agent_agent-b");
|
||||
|
||||
expect(
|
||||
deriveWorkspaceTabModel({
|
||||
...base,
|
||||
focusedTabId: "agent_agent-b",
|
||||
}).activeTabId
|
||||
).toBe("agent_agent-b");
|
||||
expect(deriveWorkspaceTabModel(base).activeTabId).toBe("agent_agent-a");
|
||||
});
|
||||
|
||||
it("prefers the route-selected target over stale focused tab state", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
],
|
||||
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
||||
focusedTabId: "agent_agent-a",
|
||||
preferredTarget: { kind: "agent", agentId: "agent-b" },
|
||||
});
|
||||
|
||||
expect(model.activeTabId).toBe("agent_agent-b");
|
||||
expect(model.activeTab?.target).toEqual({ kind: "agent", agentId: "agent-b" });
|
||||
expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-b" });
|
||||
});
|
||||
|
||||
it("re-resolves active content for a new workspace when prior focused tab is not available", () => {
|
||||
it("normalizes preferredTarget before overriding focused tab selection", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "agent_workspace-b-agent",
|
||||
target: { kind: "agent", agentId: "workspace-b-agent" },
|
||||
tabId: "file_/repo/worktree/README.md",
|
||||
target: { kind: "file", path: "/repo/worktree/README.md" },
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["agent_workspace-b-agent"],
|
||||
focusedTabId: "agent_workspace-a-agent",
|
||||
});
|
||||
|
||||
expect(model.activeTabId).toBe("agent_workspace-b-agent");
|
||||
expect(model.activeTab?.target).toEqual({
|
||||
kind: "agent",
|
||||
agentId: "workspace-b-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not materialize tabs absent from workspace tab membership", () => {
|
||||
const offending = makeAgent({
|
||||
id: "offender",
|
||||
title: "Needs permission",
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
});
|
||||
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [offending],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_123",
|
||||
target: { kind: "draft", draftId: "draft_123" },
|
||||
createdAt: 1,
|
||||
tabId: "agent_agent-a",
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_123"],
|
||||
focusedTabId: "agent_agent-a",
|
||||
preferredTarget: { kind: "file", path: "\\repo\\worktree\\README.md" },
|
||||
});
|
||||
|
||||
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(false);
|
||||
expect(model.activeTabId).toBe("file_/repo/worktree/README.md");
|
||||
expect(model.activeTab?.descriptor.target).toEqual({
|
||||
kind: "file",
|
||||
path: "/repo/worktree/README.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps retargeted agent tab id stable while upgrading descriptor data", () => {
|
||||
it("keeps retargeted tab ids stable while matching upgraded targets", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_abc",
|
||||
@@ -226,53 +121,34 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_abc"],
|
||||
});
|
||||
const initial = model.tabs[0]?.descriptor;
|
||||
expect(initial?.tabId).toBe("draft_abc");
|
||||
expect(initial?.kind).toBe("agent");
|
||||
if (initial?.kind === "agent") {
|
||||
expect(initial.titleState).toBe("loading");
|
||||
expect(initial.agentId).toBe("agent-1");
|
||||
}
|
||||
|
||||
const upgraded = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_abc",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_abc"],
|
||||
});
|
||||
const upgradedDescriptor = upgraded.tabs[0]?.descriptor;
|
||||
expect(upgradedDescriptor?.tabId).toBe("draft_abc");
|
||||
if (upgradedDescriptor?.kind === "agent") {
|
||||
expect(upgradedDescriptor.titleState).toBe("ready");
|
||||
expect(upgradedDescriptor.label).toBe("Ready title");
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers a retargeted tab when the route selects its upgraded agent target", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
|
||||
terminals: [],
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_abc",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_abc"],
|
||||
focusedTabId: "some_other_tab",
|
||||
preferredTarget: { kind: "agent", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(model.activeTabId).toBe("draft_abc");
|
||||
expect(model.activeTab?.descriptor.tabId).toBe("draft_abc");
|
||||
expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-1" });
|
||||
});
|
||||
|
||||
it("normalizes file paths and discards invalid tabs", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
tabs: [
|
||||
{
|
||||
tabId: "file_path",
|
||||
target: { kind: "file", path: "\\repo\\worktree\\README.md" },
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
tabId: "",
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(model.tabs).toHaveLength(1);
|
||||
expect(model.tabs[0]?.descriptor.target).toEqual({
|
||||
kind: "file",
|
||||
path: "/repo/worktree/README.md",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,283 +1,35 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
type WorkspaceDerivedTab,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
|
||||
type TerminalLike = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceDerivedTab = {
|
||||
descriptor: WorkspaceTabDescriptor;
|
||||
target: WorkspaceTabTarget;
|
||||
};
|
||||
|
||||
export type WorkspaceTabModel = {
|
||||
export interface WorkspaceTabModel {
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
activeTabId: string | null;
|
||||
activeTab: WorkspaceDerivedTab | null;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId;
|
||||
}
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (provider === "claude") {
|
||||
return "Claude";
|
||||
}
|
||||
if (provider === "codex") {
|
||||
return "Codex";
|
||||
}
|
||||
if (!provider) {
|
||||
return "Agent";
|
||||
}
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
|
||||
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
|
||||
if (!tab || typeof tab !== "object") {
|
||||
return null;
|
||||
}
|
||||
const tabId = trimNonEmpty(tab.tabId);
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
if (!tab.target || typeof tab.target !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
const draftId = trimNonEmpty(tab.target.draftId);
|
||||
if (!draftId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "draft", draftId },
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "agent") {
|
||||
const agentId = trimNonEmpty(tab.target.agentId);
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(tab.target.terminalId);
|
||||
if (!terminalId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "file") {
|
||||
const path = trimNonEmpty(tab.target.path);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
target: { kind: "file", path: path.replace(/\\/g, "/") },
|
||||
createdAt: tab.createdAt,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
|
||||
if (target.kind === "draft") {
|
||||
return target.draftId;
|
||||
}
|
||||
if (target.kind === "agent") {
|
||||
return `agent_${target.agentId}`;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
return buildDeterministicWorkspaceTabId(target);
|
||||
}
|
||||
|
||||
export function deriveWorkspaceTabModel(input: {
|
||||
workspaceAgents: Agent[];
|
||||
terminals: TerminalLike[];
|
||||
tabs: WorkspaceTab[];
|
||||
tabOrder: string[];
|
||||
focusedTabId?: string | null;
|
||||
preferredTarget?: WorkspaceTabTarget | null;
|
||||
}): WorkspaceTabModel {
|
||||
const tabsById = new Map<string, WorkspaceDerivedTab>();
|
||||
const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent]));
|
||||
const terminalsById = new Map(input.terminals.map((terminal) => [terminal.id, terminal]));
|
||||
|
||||
const normalizedTabs = input.tabs
|
||||
.map((tab) => normalizeWorkspaceTab(tab))
|
||||
.filter((tab): tab is WorkspaceTab => tab !== null)
|
||||
.sort((left, right) => left.createdAt - right.createdAt);
|
||||
|
||||
for (const tab of normalizedTabs) {
|
||||
if (tab.target.kind === "draft") {
|
||||
tabsById.set(tab.tabId, {
|
||||
target: tab.target,
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: "draft",
|
||||
draftId: tab.target.draftId,
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tab.target.kind === "agent") {
|
||||
const agent = agentsById.get(tab.target.agentId) ?? null;
|
||||
const label = resolveWorkspaceAgentTabLabel(agent?.title);
|
||||
const provider = agent?.provider ?? "codex";
|
||||
tabsById.set(tab.tabId, {
|
||||
target: tab.target,
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: "agent",
|
||||
agentId: tab.target.agentId,
|
||||
provider,
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tab.target.kind === "terminal") {
|
||||
const terminal = terminalsById.get(tab.target.terminalId) ?? null;
|
||||
tabsById.set(tab.tabId, {
|
||||
target: tab.target,
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: "terminal",
|
||||
terminalId: tab.target.terminalId,
|
||||
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
|
||||
subtitle: "Terminal",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tab.target.kind === "file") {
|
||||
const filePath = tab.target.path;
|
||||
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
|
||||
tabsById.set(tab.tabId, {
|
||||
target: tab.target,
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: "file",
|
||||
filePath,
|
||||
label: fileName,
|
||||
subtitle: filePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const orderedTabIds: string[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const tabId of input.tabOrder) {
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedTabId || used.has(normalizedTabId) || !tabsById.has(normalizedTabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(normalizedTabId);
|
||||
orderedTabIds.push(normalizedTabId);
|
||||
}
|
||||
|
||||
for (const tabId of tabsById.keys()) {
|
||||
if (used.has(tabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(tabId);
|
||||
orderedTabIds.push(tabId);
|
||||
}
|
||||
|
||||
const tabs = orderedTabIds
|
||||
.map((tabId) => tabsById.get(tabId) ?? null)
|
||||
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
|
||||
|
||||
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
|
||||
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||
const preferredTarget = input.preferredTarget ?? null;
|
||||
const preferredTabId = (() => {
|
||||
if (!preferredTarget) {
|
||||
return null;
|
||||
}
|
||||
const matchingTab =
|
||||
tabs.find((tab) => tabTargetsEqual(tab.target, preferredTarget)) ?? null;
|
||||
return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget);
|
||||
})();
|
||||
|
||||
const activeTabId =
|
||||
preferredTabId && openTabIds.has(preferredTabId)
|
||||
? preferredTabId
|
||||
: focusedTabId && openTabIds.has(focusedTabId)
|
||||
? focusedTabId
|
||||
: tabs[0]?.descriptor.tabId ?? null;
|
||||
|
||||
const activeTab = activeTabId
|
||||
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
|
||||
: null;
|
||||
|
||||
const paneState = deriveWorkspacePaneState({
|
||||
tabs: input.tabs,
|
||||
focusedTabId: input.focusedTabId,
|
||||
preferredTarget: input.preferredTarget,
|
||||
});
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
tabs: paneState.tabs,
|
||||
activeTabId: paneState.activeTabId,
|
||||
activeTab: paneState.activeTab,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,51 +1,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 = {
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type WorkspaceTabDescriptor =
|
||||
| {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: "draft";
|
||||
draftId: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: "agent";
|
||||
agentId: string;
|
||||
provider: Agent["provider"];
|
||||
label: string;
|
||||
subtitle: string;
|
||||
titleState: "ready" | "loading";
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: "terminal";
|
||||
terminalId: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: "file";
|
||||
filePath: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
};
|
||||
export interface WorkspaceTabDescriptor {
|
||||
key: string;
|
||||
tabId: string;
|
||||
kind: WorkspaceTabTarget["kind"];
|
||||
target: WorkspaceTabTarget;
|
||||
}
|
||||
|
||||
@@ -9,33 +9,13 @@ beforeEach(() => {
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
visibleWorkspaceTargets: [],
|
||||
workspaceTabActionRequest: null,
|
||||
nextWorkspaceTabActionRequestId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard-shortcuts-store", () => {
|
||||
it("queues and clears workspace tab action requests", () => {
|
||||
useKeyboardShortcutsStore.getState().requestWorkspaceTabAction({
|
||||
serverId: "srv-1",
|
||||
workspaceId: "/repo/main",
|
||||
kind: "navigate-index",
|
||||
index: 3,
|
||||
});
|
||||
|
||||
const request = useKeyboardShortcutsStore.getState().workspaceTabActionRequest;
|
||||
expect(request).toEqual({
|
||||
id: 1,
|
||||
serverId: "srv-1",
|
||||
workspaceId: "/repo/main",
|
||||
kind: "navigate-index",
|
||||
index: 3,
|
||||
});
|
||||
|
||||
useKeyboardShortcutsStore.getState().clearWorkspaceTabActionRequest(999);
|
||||
expect(useKeyboardShortcutsStore.getState().workspaceTabActionRequest).not.toBeNull();
|
||||
|
||||
useKeyboardShortcutsStore.getState().clearWorkspaceTabActionRequest(1);
|
||||
expect(useKeyboardShortcutsStore.getState().workspaceTabActionRequest).toBeNull();
|
||||
it("toggles command center open state", () => {
|
||||
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(false);
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true);
|
||||
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
||||
|
||||
export type WorkspaceTabActionRequest =
|
||||
| {
|
||||
id: number;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "new" | "close-current";
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "navigate-index";
|
||||
index: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "navigate-relative";
|
||||
delta: 1 | -1;
|
||||
};
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
projectPickerOpen: boolean;
|
||||
@@ -33,8 +11,6 @@ interface KeyboardShortcutsState {
|
||||
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||
/** All visible workspace targets in top-to-bottom visual order. */
|
||||
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||
workspaceTabActionRequest: WorkspaceTabActionRequest | null;
|
||||
nextWorkspaceTabActionRequestId: number;
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setProjectPickerOpen: (open: boolean) => void;
|
||||
@@ -44,30 +20,10 @@ interface KeyboardShortcutsState {
|
||||
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||
resetModifiers: () => void;
|
||||
|
||||
requestWorkspaceTabAction: (input:
|
||||
| {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "new" | "close-current";
|
||||
}
|
||||
| {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "navigate-index";
|
||||
index: number;
|
||||
}
|
||||
| {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
kind: "navigate-relative";
|
||||
delta: 1 | -1;
|
||||
}) => void;
|
||||
clearWorkspaceTabActionRequest: (id: number) => void;
|
||||
}
|
||||
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
(set, get) => ({
|
||||
(set) => ({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
@@ -75,8 +31,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
visibleWorkspaceTargets: [],
|
||||
workspaceTabActionRequest: null,
|
||||
nextWorkspaceTabActionRequestId: 1,
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
|
||||
@@ -87,23 +41,5 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
set({ sidebarShortcutWorkspaceTargets: targets }),
|
||||
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
|
||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||
|
||||
requestWorkspaceTabAction: (input) => {
|
||||
const id = get().nextWorkspaceTabActionRequestId;
|
||||
set({
|
||||
workspaceTabActionRequest: {
|
||||
...input,
|
||||
id,
|
||||
} as WorkspaceTabActionRequest,
|
||||
nextWorkspaceTabActionRequestId: id + 1,
|
||||
});
|
||||
},
|
||||
clearWorkspaceTabActionRequest: (id) => {
|
||||
const current = get().workspaceTabActionRequest;
|
||||
if (!current || current.id !== id) {
|
||||
return;
|
||||
}
|
||||
set({ workspaceTabActionRequest: null });
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
97
packages/app/src/stores/navigation-active-workspace-store.ts
Normal file
97
packages/app/src/stores/navigation-active-workspace-store.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
1190
packages/app/src/stores/workspace-layout-actions.ts
Normal file
1190
packages/app/src/stores/workspace-layout-actions.ts
Normal file
File diff suppressed because it is too large
Load Diff
637
packages/app/src/stores/workspace-layout-store.test.ts
Normal file
637
packages/app/src/stores/workspace-layout-store.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
405
packages/app/src/stores/workspace-layout-store.ts
Normal file
405
packages/app/src/stores/workspace-layout-store.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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)}…`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
83
packages/app/src/utils/split-navigation.test.ts
Normal file
83
packages/app/src/utils/split-navigation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
251
packages/app/src/utils/split-navigation.ts
Normal file
251
packages/app/src/utils/split-navigation.ts
Normal 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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
82
packages/app/src/utils/tool-call-detail-state.test.ts
Normal file
82
packages/app/src/utils/tool-call-detail-state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
packages/app/src/utils/tool-call-detail-state.ts
Normal file
77
packages/app/src/utils/tool-call-detail-state.ts
Normal 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
Reference in New Issue
Block a user