Make workspace tab switching faster (#1251)

* Avoid workspace shell renders when toggling sidebar

* Avoid rerendering inactive workspace screens

* Avoid rerendering unchanged sidebar rows

* Keep sidebar selection state in the list

* Speed up workspace tab switching
This commit is contained in:
Mohamed Boudra
2026-05-31 15:46:17 +08:00
committed by GitHub
parent 67133e3ebc
commit f26a81798b
12 changed files with 909 additions and 323 deletions

View File

@@ -43,6 +43,70 @@ In any worktree-style or portless setup, never assume default ports.
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### React render profiling
The app has a gated React render profiler in
`packages/app/src/utils/render-profiler.tsx`. Wrap the component boundary you want
to measure with `RenderProfile`, then open the app with `?renderProfile=1`. When
the query param is absent, `RenderProfile` returns children directly and records
nothing.
Captured samples are exposed on `globalThis.__PASEO_RENDER_PROFILE__`. Call
`globalThis.__PASEO_RESET_RENDER_PROFILE__?.()` after warm-up and before the
interaction you want to measure. If a memo comparator or subscription boundary
needs explanation, call `recordRenderProfileReasons(id, reasons)` while profiling;
reason counts are exposed on `globalThis.__PASEO_RENDER_PROFILE_REASONS__`.
Use this workflow for any render investigation:
1. Add stable `RenderProfile` boundaries around the suspected root and expensive
children. Keep IDs specific enough to compare before and after.
2. Reproduce against real app state, not toy fixtures, whenever practical.
3. Record an idle baseline first. If idle is noisy, fix or account for that
before optimizing the interaction.
4. Warm up the route, reset profiler samples, run the exact interaction, then
compare `actualDuration`, render counts, and per-commit samples.
5. When a memo boundary still renders, record reasons before changing code. Do
not guess from object identity alone.
6. Keep changes that move the measured profile. Remove probes or memo wrappers
that do not move the number.
What this caught during the workspace tab investigation:
- A large apparent workspace cost was real interaction work, not daemon noise;
the idle baseline stayed near zero.
- The expensive stream rerender was mostly prop identity churn from pane context
callbacks and capability objects, not new stream data.
- Stabilizing provider actions at the pane boundary helped because every mounted
panel consumes that context.
- Comparing value-shaped capability flags beat preserving object identity through
unrelated stores.
- Some plausible fixes did not pay off: memoizing the tab row and composer draft
object barely moved the profile, so they were removed.
Existing scenario script: workspace agent/terminal tab switching. Start Expo on
web, keep a daemon available, then run:
```bash
PASEO_PROFILE_SERVER_ID=<server-id> \
PASEO_PROFILE_WORKSPACE_ID=<workspace-path> \
PASEO_PROFILE_AGENT_ID=<agent-id> \
npm run profile:workspace-tabs --workspace=@getpaseo/app
```
This script opens the app with `?renderProfile=1`, creates a temporary terminal
tab, switches between a real agent and that terminal, prints aggregated React
Profiler timings, then removes the temporary terminal. It is an example of the
workflow above, not the only way to use the profiler. Useful knobs:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 # Expo web URL
PASEO_PROFILE_SWITCH_COUNT=1 # number of agent/terminal switch pairs
PASEO_PROFILE_SWITCH_WAIT_MS=250 # delay after each click
PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
```
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync

View File

@@ -26,6 +26,7 @@
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
},

View File

@@ -0,0 +1,172 @@
import { execFileSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const baseUrl = process.env.PASEO_PROFILE_APP_URL ?? "http://localhost:19010";
const serverId = requiredEnv("PASEO_PROFILE_SERVER_ID");
const workspaceId = requiredEnv("PASEO_PROFILE_WORKSPACE_ID");
const agentId = requiredEnv("PASEO_PROFILE_AGENT_ID");
const switchCount = Number(process.env.PASEO_PROFILE_SWITCH_COUNT ?? 6);
const switchWaitMs = Number(process.env.PASEO_PROFILE_SWITCH_WAIT_MS ?? 250);
const idleWaitMs = Number(process.env.PASEO_PROFILE_IDLE_WAIT_MS ?? 0);
const dumpCommits = process.env.PASEO_PROFILE_DUMP_COMMITS === "1";
function requiredEnv(name) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function createTerminal() {
const output = execFileSync(
"npm",
[
"run",
"cli",
"--",
"terminal",
"create",
"--cwd",
workspaceId,
"--name",
"Tab Switch Profile",
"--json",
],
{ cwd: repoRoot, encoding: "utf8" },
);
const match = output.match(/\{[\s\S]*\}/);
if (!match) {
throw new Error(`Could not parse terminal create output: ${output}`);
}
return JSON.parse(match[0]).id;
}
function killTerminal(terminalId) {
execFileSync("npm", ["run", "cli", "--", "terminal", "kill", terminalId], {
cwd: repoRoot,
encoding: "utf8",
});
}
const createdTerminalId = process.env.PASEO_PROFILE_TERMINAL_ID ? null : createTerminal();
const terminalId = process.env.PASEO_PROFILE_TERMINAL_ID ?? createdTerminalId;
const workspaceSegment = `b64_${Buffer.from(workspaceId, "utf8").toString("base64url")}`;
const workspaceUrl = `${baseUrl}/h/${serverId}/workspace/${workspaceSegment}`;
function tabTestId(kind, id) {
return `workspace-tab-${kind}_${id}`;
}
function summarize(samples) {
const byId = new Map();
for (const sample of samples) {
const current = byId.get(sample.id) ?? {
renders: 0,
actualDuration: 0,
baseDuration: 0,
};
current.renders += 1;
current.actualDuration += sample.actualDuration;
current.baseDuration += sample.baseDuration;
byId.set(sample.id, current);
}
return [...byId.entries()]
.map(([id, value]) => Object.assign({ id }, value))
.sort((left, right) => right.actualDuration - left.actualDuration);
}
function summarizeCommits(samples) {
const byCommit = new Map();
for (const sample of samples) {
const key = String(sample.commitTime);
const current = byCommit.get(key) ?? {
commitTime: sample.commitTime,
totalActualDuration: 0,
samples: [],
};
current.totalActualDuration += sample.actualDuration;
current.samples.push({
id: sample.id,
actualDuration: sample.actualDuration,
baseDuration: sample.baseDuration,
});
byCommit.set(key, current);
}
return [...byCommit.values()].sort((left, right) => left.commitTime - right.commitTime);
}
async function openIntent(page, value) {
const url = `${workspaceUrl}?renderProfile=1&open=${encodeURIComponent(value)}`;
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.getByTestId("workspace-tabs-row").waitFor({ timeout: 60_000 });
}
async function clickTab(page, testId) {
await page.getByTestId(testId).click();
await page.waitForTimeout(switchWaitMs);
}
async function main() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
try {
await openIntent(page, `agent:${agentId}`);
await openIntent(page, `terminal:${terminalId}`);
const agentTab = tabTestId("agent", agentId);
const terminalTab = tabTestId("terminal", terminalId);
await page.getByTestId(agentTab).waitFor({ timeout: 60_000 });
await page.getByTestId(terminalTab).waitFor({ timeout: 60_000 });
await clickTab(page, agentTab);
await clickTab(page, terminalTab);
await page.waitForTimeout(500);
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
if (idleWaitMs > 0) {
await page.waitForTimeout(idleWaitMs);
}
for (let index = 0; index < switchCount; index += 1) {
await clickTab(page, agentTab);
await clickTab(page, terminalTab);
}
const samples = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__ ?? []);
const reasons = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {});
console.log(
JSON.stringify(
{
appUrl: baseUrl,
serverId,
workspaceId,
agentId,
terminalId,
switchCount,
switchWaitMs,
idleWaitMs,
samples: samples.length,
summary: summarize(samples),
reasons,
commits: dumpCommits ? summarizeCommits(samples) : undefined,
},
null,
2,
),
);
} finally {
await browser.close();
if (createdTerminalId) {
killTerminal(createdTerminalId);
}
}
}
await main();

View File

@@ -41,6 +41,7 @@ import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
AgentCapabilityFlags,
AgentPermissionAction,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
@@ -77,6 +78,7 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -214,6 +216,18 @@ export interface AgentStreamViewProps {
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
"supportsStreaming",
"supportsSessionPersistence",
"supportsDynamicModes",
"supportsMcpServers",
"supportsReasoningStream",
"supportsToolInvocations",
"supportsRewindConversation",
"supportsRewindFiles",
"supportsRewindBoth",
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
@@ -745,7 +759,68 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
);
export const AgentStreamView = memo(AgentStreamViewComponent);
function agentCapabilityFlagsEqual(
left: AgentCapabilityFlags | undefined,
right: AgentCapabilityFlags | undefined,
): boolean {
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
}
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
if (left.id !== right.id) reasons.push("agent.id");
if (left.status !== right.status) reasons.push("agent.status");
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
reasons.push("agent.capabilities");
}
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
reasons.push("agent.projectPlacement.checkout.cwd");
}
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
return reasons;
}
function bottomAnchorRouteRequestsEqual(
left: BottomAnchorRouteRequest | null | undefined,
right: BottomAnchorRouteRequest | null | undefined,
): boolean {
return (
left?.agentId === right?.agentId &&
left?.reason === right?.reason &&
left?.requestKey === right?.requestKey
);
}
function agentStreamViewPropsEqual(
left: AgentStreamViewProps,
right: AgentStreamViewProps,
): boolean {
const reasons: string[] = [];
if (left.agentId !== right.agentId) reasons.push("agentId");
if (left.serverId !== right.serverId) reasons.push("serverId");
reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent));
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
if (
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
) {
reasons.push("routeBottomAnchorRequest");
}
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.toast !== right.toast) reasons.push("toast");
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
return reasons.length === 0;
}
export const AgentStreamView = memo(AgentStreamViewComponent, agentStreamViewPropsEqual);
AgentStreamView.displayName = "AgentStreamView";
interface ToolCallSlotProps extends Omit<

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, type ReactElement } from "react";
import { memo, useCallback, useMemo, useRef, type ReactElement } from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
@@ -43,7 +43,7 @@ interface SortableItemProps<T> {
useDragHandle: boolean;
}
function SortableItem<T>({
function SortableItemInner<T>({
id,
item,
index,
@@ -118,6 +118,8 @@ function SortableItem<T>({
);
}
const SortableItem = memo(SortableItemInner) as typeof SortableItemInner;
export function DraggableList<T>({
data,
keyExtractor,

View File

@@ -17,6 +17,7 @@ import { ProjectIconView } from "@/components/project-icon-view";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import {
memo,
useCallback,
useMemo,
useState,
@@ -27,7 +28,11 @@ import {
type Ref,
} from "react";
import { router, usePathname, type Href } from "expo-router";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { type GestureType } from "react-native-gesture-handler";
@@ -101,7 +106,6 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -173,6 +177,52 @@ function getPrIconUniMapping(state: PrHint["state"]) {
}
}
function useStableProjectIconData(
data: (string | null)[],
signature: string,
): readonly (string | null)[] {
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
if (stableRef.current?.signature !== signature) {
stableRef.current = { signature, data };
}
return stableRef.current.data;
}
function isWorkspaceSelected(input: {
selection: ActiveWorkspaceSelection | null;
serverId: string | null;
workspaceId: string;
enabled: boolean;
}): boolean {
return (
input.enabled &&
input.selection?.serverId === input.serverId &&
input.selection.workspaceId === input.workspaceId
);
}
function isProjectSelectedByRoute(input: {
selection: ActiveWorkspaceSelection | null;
project: SidebarProjectEntry;
serverId: string | null;
enabled: boolean;
}): boolean {
return (
input.enabled &&
input.selection?.serverId === input.serverId &&
input.project.workspaces.some(
(workspace) => workspace.workspaceId === input.selection?.workspaceId,
)
);
}
function selectionForSelectedWorkspace(
selected: boolean,
workspace: SidebarWorkspaceEntry,
): ActiveWorkspaceSelection | null {
return selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null;
}
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[];
serverId: string | null;
@@ -1493,7 +1543,6 @@ function WorkspaceRowWithMenu({
isCreating?: boolean;
}) {
const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
@@ -1516,9 +1565,9 @@ function WorkspaceRowWithMenu({
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection,
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
});
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
}, [selected, workspace]);
const archiveWorktreeAfterConfirmation = useCallback(async () => {
if (isArchiving) {
@@ -1755,15 +1804,14 @@ function NonGitProjectRowWithMenuContent({
}) {
const toast = useToast();
const contextMenu = useContextMenu();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection,
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
});
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
}, [selected, workspace]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1883,6 +1931,7 @@ function FlattenedProjectRow({
onRemoveProject,
removeProjectStatus,
selectionEnabled,
activeWorkspaceSelection,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -1901,13 +1950,15 @@ function FlattenedProjectRow({
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
selectionEnabled: boolean;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
}) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
selectionEnabled &&
activeWorkspaceSelection?.serverId === serverId &&
activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId;
const selected = isWorkspaceSelected({
selection: activeWorkspaceSelection,
serverId,
workspaceId: rowModel.workspace.workspaceId,
enabled: selectionEnabled,
});
if (!workspace) {
return null;
@@ -1965,7 +2016,7 @@ interface WorkspaceRowItemProps {
isCreating?: boolean;
selectionEnabled: boolean;
serverId: string | null;
currentPathname: string | null;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
@@ -1980,7 +2031,7 @@ function WorkspaceRowItem({
isCreating = false,
selectionEnabled,
serverId,
currentPathname,
activeWorkspaceSelection,
onWorkspacePress,
drag,
isDragging = false,
@@ -1991,8 +2042,8 @@ function WorkspaceRowItem({
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId, { currentPathname });
}, [serverId, onWorkspacePress, workspace.workspaceId, currentPathname]);
navigateToWorkspace(serverId, workspace.workspaceId);
}, [serverId, onWorkspacePress, workspace.workspaceId]);
return (
<WorkspaceRow
@@ -2001,7 +2052,12 @@ function WorkspaceRowItem({
showShortcutBadge={showShortcutBadge}
canCopyBranchName={canCopyBranchName}
isCreating={isCreating}
selectionEnabled={selectionEnabled}
selected={isWorkspaceSelected({
selection: activeWorkspaceSelection,
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
enabled: selectionEnabled,
})}
onPress={handlePress}
drag={drag ?? noop}
isDragging={isDragging}
@@ -2010,6 +2066,39 @@ function WorkspaceRowItem({
);
}
function areWorkspaceRowItemPropsEqual(
previous: WorkspaceRowItemProps,
next: WorkspaceRowItemProps,
): boolean {
const previousSelected = isWorkspaceSelected({
selection: previous.activeWorkspaceSelection,
serverId: previous.workspace.serverId,
workspaceId: previous.workspace.workspaceId,
enabled: previous.selectionEnabled,
});
const nextSelected = isWorkspaceSelected({
selection: next.activeWorkspaceSelection,
serverId: next.workspace.serverId,
workspaceId: next.workspace.workspaceId,
enabled: next.selectionEnabled,
});
return (
previous.workspace === next.workspace &&
previous.shortcutNumber === next.shortcutNumber &&
previous.showShortcutBadge === next.showShortcutBadge &&
previous.canCopyBranchName === next.canCopyBranchName &&
previous.isCreating === next.isCreating &&
previous.serverId === next.serverId &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previousSelected === nextSelected
);
}
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
function WorkspaceRow({
workspace,
shortcutNumber,
@@ -2020,7 +2109,7 @@ function WorkspaceRow({
dragHandleProps,
canCopyBranchName,
isCreating = false,
selectionEnabled,
selected,
}: {
workspace: SidebarWorkspaceEntry;
shortcutNumber: number | null;
@@ -2031,14 +2120,9 @@ function WorkspaceRow({
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
isCreating?: boolean;
selectionEnabled: boolean;
selected: boolean;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
selectionEnabled &&
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection.workspaceId === workspace.workspaceId;
if (!hydratedWorkspace) {
return null;
@@ -2074,12 +2158,12 @@ function ProjectBlock({
onWorkspacePress,
onWorkspaceReorder,
onWorktreeCreated,
currentPathname,
drag,
isDragging,
dragHandleProps,
useNestable,
creatingWorkspaceIds,
activeWorkspaceSelection,
}: {
project: SidebarProjectEntry;
collapsed: boolean;
@@ -2094,12 +2178,12 @@ function ProjectBlock({
onWorkspacePress?: () => void;
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
onWorktreeCreated?: (workspaceId: string) => void;
currentPathname: string | null;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
useNestable: boolean;
creatingWorkspaceIds: ReadonlySet<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
}) {
const rowModel = useMemo(
() =>
@@ -2110,15 +2194,12 @@ function ProjectBlock({
[collapsed, project],
);
const projectWorkspaceIds = useMemo(
() => project.workspaces.map((workspace) => workspace.workspaceId),
[project.workspaces],
);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const isProjectActive =
selectionEnabled &&
activeWorkspaceSelection?.serverId === serverId &&
projectWorkspaceIds.includes(activeWorkspaceSelection.workspaceId);
const active = isProjectSelectedByRoute({
selection: activeWorkspaceSelection,
serverId,
project,
enabled: selectionEnabled,
});
const renderWorkspaceRow = useCallback(
(
@@ -2130,7 +2211,7 @@ function ProjectBlock({
},
) => {
return (
<WorkspaceRowItem
<MemoWorkspaceRowItem
workspace={item}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
@@ -2138,7 +2219,7 @@ function ProjectBlock({
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
selectionEnabled={selectionEnabled}
serverId={serverId}
currentPathname={currentPathname}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
@@ -2148,8 +2229,8 @@ function ProjectBlock({
},
[
project.projectKind,
activeWorkspaceSelection,
creatingWorkspaceIds,
currentPathname,
onWorkspacePress,
serverId,
selectionEnabled,
@@ -2228,10 +2309,8 @@ function ProjectBlock({
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, flattenedRowWorkspaceId, {
currentPathname,
});
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress, currentPathname]);
navigateToWorkspace(serverId, flattenedRowWorkspaceId);
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress]);
const handleToggleCollapsed = useCallback(() => {
onToggleCollapsed(project.projectKey);
@@ -2254,10 +2333,11 @@ function ProjectBlock({
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
isProjectActive={isProjectActive}
isProjectActive={active}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
/>
) : (
<>
@@ -2271,7 +2351,7 @@ function ProjectBlock({
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
@@ -2303,6 +2383,46 @@ function ProjectBlock({
);
}
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
const previousActive = isProjectSelectedByRoute({
selection: previous.activeWorkspaceSelection,
project: previous.project,
serverId: previous.serverId,
enabled: previous.selectionEnabled,
});
const nextActive = isProjectSelectedByRoute({
selection: next.activeWorkspaceSelection,
project: next.project,
serverId: next.serverId,
enabled: next.selectionEnabled,
});
return (
previous.project === next.project &&
previous.collapsed === next.collapsed &&
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
previous.serverId === next.serverId &&
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previous.useNestable === next.useNestable &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previousActive === nextActive
);
}
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
export function SidebarWorkspaceList({
projects,
serverId,
@@ -2333,6 +2453,7 @@ export function SidebarWorkspaceList({
[pathname],
);
const selectionEnabled = isWorkspaceRoute;
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const projectIconRequests = useMemo(() => {
if (!serverId) {
@@ -2374,6 +2495,12 @@ export function SidebarWorkspaceList({
})),
});
const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000");
const projectIconData = useStableProjectIconData(
projectIconQueries.map((query) => query.data ?? null),
projectIconSignature,
);
const projectIconByProjectKey = useMemo(() => {
const iconByServerAndCwd = new Map<string, string | null>();
for (let index = 0; index < projectIconRequests.length; index += 1) {
@@ -2381,10 +2508,7 @@ export function SidebarWorkspaceList({
if (!request) {
continue;
}
iconByServerAndCwd.set(
`${request.serverId}:${request.cwd}`,
projectIconQueries[index]?.data ?? null,
);
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
}
const byProject = new Map<string, string | null>();
@@ -2398,7 +2522,7 @@ export function SidebarWorkspaceList({
}
return byProject;
}, [projectIconQueries, projectIconRequests, projects, serverId]);
}, [projectIconData, projectIconRequests, projects, serverId]);
useEffect(() => {
const timeouts = creatingWorkspaceTimeoutsRef.current;
@@ -2532,7 +2656,7 @@ export function SidebarWorkspaceList({
const renderProject = useCallback(
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
return (
<ProjectBlock
<MemoProjectBlock
project={item}
collapsed={collapsedProjectKeys.has(item.projectKey)}
displayName={item.projectName}
@@ -2546,23 +2670,23 @@ export function SidebarWorkspaceList({
onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder}
onWorktreeCreated={handleWorktreeCreated}
currentPathname={pathname}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
/>
);
},
[
collapsedProjectKeys,
activeWorkspaceSelection,
handleWorktreeCreated,
handleWorkspaceReorder,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
pathname,
projectIconByProjectKey,
selectionEnabled,
serverId,

View File

@@ -67,6 +67,7 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
@@ -78,7 +79,6 @@ interface SplitContainerProps {
isWorkspaceFocused: boolean;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
closingTabIds: Set<string>;
onNavigateTab: (tabId: string) => void;
@@ -215,14 +215,16 @@ const MountedTabSlot = memo(function MountedTabSlot({
}, [onFocusPane, paneId]);
return (
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
</RenderProfile>
);
});
@@ -355,7 +357,6 @@ export function SplitContainer({
isWorkspaceFocused,
uiTabs,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -553,63 +554,64 @@ export function SplitContainer({
);
return (
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
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>
<RenderProfile id="SplitContainer">
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
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>
</RenderProfile>
);
}
@@ -711,7 +713,6 @@ function SplitNodeView({
normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -764,7 +765,6 @@ function SplitNodeView({
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
@@ -810,7 +810,6 @@ function SplitNodeView({
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
@@ -862,7 +861,6 @@ function SplitPaneView({
normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -995,66 +993,67 @@ function SplitPaneView({
);
return (
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={paneTabsStyle}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/>
</View>
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={paneTabsStyle}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/>
</View>
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
</View>
</RenderProfile>
);
}

View File

@@ -1,6 +1,6 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { SquarePen } from "lucide-react-native";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -38,6 +38,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
import {
type HostRuntimeConnectionStatus,
@@ -1095,42 +1096,52 @@ function ChatAgentReadyContent({
agentId,
}),
});
const streamSection = (
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
);
const composerSection = (
<RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
cwd={cwd}
isSubmitLoading={false}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
</RenderProfile>
);
const streamContent = (
<ReanimatedAnimated.View style={animatedContentStyle}>{streamSection}</ReanimatedAnimated.View>
);
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
<View style={styles.root}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={animatedContentStyle}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
{contentContainer}
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
cwd={cwd}
isSubmitLoading={false}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
{composerSection}
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
@@ -1158,7 +1169,7 @@ function ChatAgentReadyContent({
);
}
function AgentStreamSection({
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
serverId,
agentId,
@@ -1222,7 +1233,7 @@ function AgentStreamSection({
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
});
function AgentComposerSection({
agentId,

View File

@@ -62,6 +62,7 @@ import {
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
@@ -148,7 +149,6 @@ interface WorkspaceDesktopTabsRowProps {
tabs: WorkspaceDesktopTabRowItem[];
normalizedServerId: string;
normalizedWorkspaceId: string;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -255,7 +255,6 @@ function TabChip({
presentation,
tooltipLabel,
resolvedTab,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -273,7 +272,6 @@ function TabChip({
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;
@@ -311,13 +309,11 @@ function TabChip({
const handleTabHoverIn = useCallback(() => {
setHovered(true);
setHoveredTabKey(tab.key);
}, [setHoveredTabKey, tab.key]);
}, []);
const handleTabHoverOut = useCallback(() => {
setHovered(false);
setHoveredTabKey((current) => (current === tab.key ? null : current));
}, [setHoveredTabKey, tab.key]);
}, []);
const handleNavigateTab = useCallback(() => {
onNavigateTab(tab.tabId);
@@ -328,14 +324,12 @@ function TabChip({
}, []);
const handleCloseButtonHoverIn = useCallback(() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
}, [setHoveredCloseTabKey, tab.key]);
const handleCloseButtonHoverOut = useCallback(() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
}, [setHoveredCloseTabKey, tab.key]);
const handleCloseButtonPress = useCallback(
(event: { stopPropagation?: () => void }) => {
@@ -466,7 +460,6 @@ export function WorkspaceDesktopTabsRow({
tabs,
normalizedServerId,
normalizedWorkspaceId,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -613,7 +606,6 @@ export function WorkspaceDesktopTabsRow({
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
@@ -640,7 +632,6 @@ export function WorkspaceDesktopTabsRow({
onReloadAgent,
onRenameTab,
setHoveredCloseTabKey,
setHoveredTabKey,
tabDropPreviewIndex,
tabs.length,
],
@@ -656,7 +647,7 @@ export function WorkspaceDesktopTabsRow({
[layout.requiresHorizontalScrollFallback],
);
return (
const row = (
<View
style={styles.tabsContainer}
testID="workspace-tabs-row"
@@ -787,8 +778,9 @@ export function WorkspaceDesktopTabsRow({
</View>
</View>
);
}
return <RenderProfile id="WorkspaceDesktopTabsRow">{row}</RenderProfile>;
}
function ResolvedDesktopTabChip({
item,
isFocused,
@@ -807,7 +799,6 @@ function ResolvedDesktopTabChip({
resolvedTabWidth,
showLabel,
showCloseButton,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -832,7 +823,6 @@ function ResolvedDesktopTabChip({
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;
@@ -896,7 +886,6 @@ function ResolvedDesktopTabChip({
presentation={presentation}
tooltipLabel={tooltipLabel}
resolvedTab={resolvedTab}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}

View File

@@ -6,9 +6,11 @@ import {
PaneProvider,
type PaneContextValue,
} from "@/panels/pane-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { RenderProfile } from "@/utils/render-profiler";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface WorkspacePaneContentModel {
@@ -72,6 +74,35 @@ export function WorkspacePaneContent({
onFocusPane,
}: WorkspacePaneContentProps) {
const { Component, key, paneContextValue } = content;
const openTab = useStableEvent(paneContextValue.openTab);
const closeCurrentTab = useStableEvent(paneContextValue.closeCurrentTab);
const retargetCurrentTab = useStableEvent(paneContextValue.retargetCurrentTab);
const openFileInWorkspace = useStableEvent(paneContextValue.openFileInWorkspace);
const openImportSheet = useStableEvent(paneContextValue.openImportSheet);
const stablePaneContextValue = useMemo(
() => ({
serverId: paneContextValue.serverId,
workspaceId: paneContextValue.workspaceId,
tabId: paneContextValue.tabId,
target: paneContextValue.target,
openTab,
closeCurrentTab,
retargetCurrentTab,
openFileInWorkspace,
openImportSheet,
}),
[
closeCurrentTab,
openFileInWorkspace,
openImportSheet,
openTab,
paneContextValue.serverId,
paneContextValue.tabId,
paneContextValue.target,
paneContextValue.workspaceId,
retargetCurrentTab,
],
);
const paneFocusValue = useMemo(
() =>
createPaneFocusContextValue({
@@ -83,10 +114,14 @@ export function WorkspacePaneContent({
);
return (
<PaneProvider value={paneContextValue}>
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
<RenderProfile
id={`WorkspacePaneContent:${paneContextValue.target.kind}:${paneContextValue.tabId}`}
>
<PaneProvider value={stablePaneContextValue}>
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
</RenderProfile>
);
}

View File

@@ -177,6 +177,7 @@ import {
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
@@ -734,16 +735,43 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
: styles.mobileMountedTabSlotHidden;
return (
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
</RenderProfile>
);
});
interface MobileExplorerOpenGestureSurfaceProps {
children: ReactNode;
onOpenExplorer: () => void;
}
function MobileExplorerOpenGestureSurface({
children,
onOpenExplorer,
}: MobileExplorerOpenGestureSurfaceProps) {
const canOpenExplorerFromAgentView = usePanelStore(
(state) =>
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: canOpenExplorerFromAgentView,
onOpen: onOpenExplorer,
});
return (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.content}>{children}</View>
</GestureDetector>
);
}
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
@@ -770,7 +798,11 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
return tabDescriptorMap;
}
export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: WorkspaceScreenProps) {
export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId,
workspaceId,
isRouteFocused,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
@@ -783,7 +815,7 @@ export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: Works
/>
</ExplorerSidebarAnimationProvider>
);
}
});
interface UseCloseTabsResult {
closingTabIds: Set<string>;
@@ -1657,10 +1689,6 @@ function WorkspaceScreenContent({
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const canOpenExplorerFromAgentView = usePanelStore(
(state) =>
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout,
@@ -1712,11 +1740,6 @@ function WorkspaceScreenContent({
[isExplorerOpen],
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && canOpenExplorerFromAgentView,
onOpen: openExplorerForWorkspace,
});
useEffect(() => {
if (!isRouteFocused || isWeb || !isExplorerOpen) {
return;
@@ -2178,7 +2201,31 @@ function WorkspaceScreenContent({
],
);
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
request,
paneId,
parentTabId,
focusPaneBeforeOpen,
}: {
request: WorkspaceFileOpenRequest;
paneId?: string | null;
parentTabId: string;
focusPaneBeforeOpen?: boolean;
}) {
if (focusPaneBeforeOpen && paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, paneId);
}
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
location: request.location,
sourcePaneId: paneId ?? undefined,
parentTabId,
});
return;
}
handleOpenFileFromChat(request.location, { parentTabId });
});
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
useWorkspaceTabRename({
@@ -2300,7 +2347,6 @@ function WorkspaceScreenContent({
}
removeTerminalFromCache(terminalId);
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2349,7 +2395,6 @@ function WorkspaceScreenContent({
}
}
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2374,7 +2419,6 @@ function WorkspaceScreenContent({
tabId: string;
target?: WorkspaceTabTarget | null;
}) {
setHoveredTabKey((current) => (current === input.tabId ? null : current));
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
@@ -2556,7 +2600,6 @@ function WorkspaceScreenContent({
});
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey],
@@ -2851,26 +2894,19 @@ function WorkspaceScreenContent({
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
},
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, input.paneId);
}
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
location: request.location,
sourcePaneId: input.paneId ?? undefined,
parentTabId: input.tab.tabId,
});
return;
}
handleOpenFileFromChat(request.location, { parentTabId: input.tab.tabId });
handleOpenWorkspaceFileFromPane({
request,
paneId: input.paneId,
parentTabId: input.tab.tabId,
focusPaneBeforeOpen: input.focusPaneBeforeOpen,
});
},
onOpenImportSheet: openImportSheet,
}),
[
handleCloseTabById,
handleOpenFileFromChat,
handleOpenFileFromChatInSidePane,
focusWorkspacePane,
handleOpenWorkspaceFileFromPane,
navigateToTabId,
normalizedServerId,
normalizedWorkspaceId,
@@ -3193,9 +3229,9 @@ function WorkspaceScreenContent({
`${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`,
[normalizedServerId, normalizedWorkspaceId],
);
const desktopContent = useMemo(() => {
const desktopSplitContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
return content;
return null;
}
return (
<SplitContainer
@@ -3207,7 +3243,6 @@ function WorkspaceScreenContent({
isWorkspaceFocused={isRouteFocused}
uiTabs={uiTabs}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={navigateToTabId}
@@ -3234,7 +3269,6 @@ function WorkspaceScreenContent({
/>
);
}, [
content,
canRenderDesktopPaneSplits,
workspaceLayout,
persistenceKey,
@@ -3267,6 +3301,7 @@ function WorkspaceScreenContent({
handleReorderTabsInPane,
renderSplitPaneEmptyState,
]);
const desktopContent = desktopSplitContent ?? content;
const workspaceCenterColumn = (
<View style={styles.centerColumn}>
@@ -3343,7 +3378,6 @@ function WorkspaceScreenContent({
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
@@ -3369,12 +3403,9 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector
gesture={explorerOpenGesture}
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
>
<View style={styles.content}>{content}</View>
</GestureDetector>
<MobileExplorerOpenGestureSurface onOpenExplorer={openExplorerForWorkspace}>
{content}
</MobileExplorerOpenGestureSurface>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
@@ -3385,44 +3416,46 @@ function WorkspaceScreenContent({
return (
gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
</FloatingPanelPortalHostNameProvider>
<RenderProfile id="WorkspaceScreenContent">
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
</FloatingPanelPortalHostNameProvider>
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
</View>
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={renamingTab}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={renamingTab}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</RenderProfile>
</WorkspaceFocusProvider>
)
);

View File

@@ -0,0 +1,81 @@
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from "react";
export interface RenderProfileSample {
id: string;
phase: "mount" | "update" | "nested-update";
actualDuration: number;
baseDuration: number;
startTime: number;
commitTime: number;
}
declare global {
var __PASEO_RENDER_PROFILE__: RenderProfileSample[] | undefined;
var __PASEO_RENDER_PROFILE_REASONS__: Record<string, Record<string, number>> | undefined;
var __PASEO_RESET_RENDER_PROFILE__: (() => void) | undefined;
}
function getSearchParam(name: string): string | null {
const location = Reflect.get(globalThis, "location");
if (!location || typeof location !== "object") {
return null;
}
const search = Reflect.get(location, "search");
if (typeof search !== "string") {
return null;
}
return new URLSearchParams(search).get(name);
}
function isRenderProfileEnabled(): boolean {
return getSearchParam("renderProfile") === "1";
}
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
) => {
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
globalThis.__PASEO_RENDER_PROFILE__.push({
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
});
};
export function RenderProfile({ id, children }: { id: string; children: ReactNode }) {
if (!isRenderProfileEnabled()) {
return children;
}
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
globalThis.__PASEO_RESET_RENDER_PROFILE__ = () => {
globalThis.__PASEO_RENDER_PROFILE__ = [];
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
};
return (
<Profiler id={id} onRender={onRender}>
{children}
</Profiler>
);
}
export function recordRenderProfileReasons(id: string, reasons: string[]) {
if (!isRenderProfileEnabled() || reasons.length === 0) {
return;
}
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {});
for (const reason of reasons) {
counts[reason] = (counts[reason] ?? 0) + 1;
}
}