perf(app): keep warm workspace switching responsive (#3447)
* perf(app): reduce warm workspace switch work Retained workspaces avoided mounts but pane activity still rerendered the full composer, message input, and agent-control presentation. Keep the activity contract intact while moving focus-sensitive behavior behind small context consumers, and add a Playwright benchmark that measures rapid retained workspace switching on one browser clock. * perf(app): accelerate retained workspace switching Avoid repeated retained-stream layout reconciliation and unrelated workspace structure recomputation. Use the lightweight route transition while the host is already focused, while preserving targeted root POP_TO for returns from app-wide routes where it prevents duplicate hidden decks. * perf(app): defer composer focus during workspace switches * perf(app): retain recently used workspaces longer Keep up to ten workspace screens warm so normal multi-workspace navigation does not repeatedly rebuild recently used timelines. Inactive screens expire after ten minutes, while the active screen remains protected and capacity eviction stays least-recently-used. * fix(app): reconcile retained timeline geometry * fix(app): refresh retained reading position
This commit is contained in:
@@ -1220,18 +1220,45 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => {
|
||||
const observed = new Map<Element, ReturnType<typeof vi.fn>>();
|
||||
it("keeps the retained viewport mounted while inactive and reconciles it once on return", async () => {
|
||||
interface ResizeObservation {
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
observer: ResizeObserver;
|
||||
}
|
||||
const observed = new Map<Element, ResizeObservation>();
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
configurable: true,
|
||||
value: class ResizeObserver {
|
||||
value: class TestResizeObserver {
|
||||
readonly disconnect = vi.fn();
|
||||
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
|
||||
observe(target: Element) {
|
||||
observed.set(target, this.disconnect);
|
||||
observed.set(target, {
|
||||
callback: this.callback,
|
||||
disconnect: this.disconnect,
|
||||
observer: this as unknown as ResizeObserver,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const notifyResize = (...targets: Element[]) => {
|
||||
const observation = targets[0] ? observed.get(targets[0]) : undefined;
|
||||
if (!observation) {
|
||||
throw new Error("Expected observed resize target");
|
||||
}
|
||||
observation.callback(
|
||||
targets.map(
|
||||
(target) =>
|
||||
({
|
||||
target,
|
||||
contentRect: target.getBoundingClientRect(),
|
||||
}) as ResizeObserverEntry,
|
||||
),
|
||||
observation.observer,
|
||||
);
|
||||
};
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
@@ -1239,6 +1266,7 @@ describe("createWebStreamStrategy", () => {
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onReadingPositionChange = vi.fn();
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
@@ -1257,6 +1285,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
@@ -1278,16 +1307,50 @@ describe("createWebStreamStrategy", () => {
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
const contentNode = scrollContainer.firstElementChild;
|
||||
if (!(contentNode instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat content node");
|
||||
}
|
||||
const firstRow = contentNode.querySelector('[data-history-row-id="message-1"]');
|
||||
const secondRow = contentNode.querySelector('[data-history-row-id="message-2"]');
|
||||
if (!(firstRow instanceof HTMLElement) || !(secondRow instanceof HTMLElement)) {
|
||||
throw new Error("Expected retained history rows");
|
||||
}
|
||||
let rowsShifted = false;
|
||||
scrollContainer.getBoundingClientRect = vi.fn(() => ({ top: 0 }) as DOMRect);
|
||||
firstRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 4 : 120 }) as DOMRect);
|
||||
secondRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 120 : 160 }) as DOMRect);
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const viewportObserverDisconnect = observed.get(scrollContainer);
|
||||
expect(viewportObserverDisconnect).toBeDefined();
|
||||
const viewportObservation = observed.get(scrollContainer);
|
||||
expect(viewportObservation).toBeDefined();
|
||||
act(() => notifyResize(scrollContainer, contentNode));
|
||||
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-1");
|
||||
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
|
||||
expect(viewportObserverDisconnect).toHaveBeenCalledOnce();
|
||||
expect(viewportObservation?.disconnect).toHaveBeenCalledOnce();
|
||||
scrollTo.mockClear();
|
||||
rowsShifted = true;
|
||||
act(() => root?.render(renderWithActivity(true)));
|
||||
act(() => notifyResize(scrollContainer, contentNode));
|
||||
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-2");
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
||||
act(() => root?.render(renderWithActivity(true)));
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
act(() => notifyResize(scrollContainer, contentNode));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
|
||||
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
|
||||
@@ -202,6 +202,65 @@ function syncNearBottom(
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
interface ActiveFollowOutputLayout {
|
||||
scrollContainer: HTMLElement | null;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
activationKey: string;
|
||||
isActivationReady: boolean;
|
||||
renderLiveAuxiliary: StreamRenderInput["renderers"]["renderLiveAuxiliary"];
|
||||
historyMounted: StreamRenderInput["segments"]["historyMounted"];
|
||||
historyVirtualized: StreamRenderInput["segments"]["historyVirtualized"];
|
||||
liveHead: StreamRenderInput["segments"]["liveHead"];
|
||||
virtualTotalSize: number;
|
||||
}
|
||||
|
||||
function activeFollowOutputLayoutsEqual(
|
||||
previous: ActiveFollowOutputLayout,
|
||||
next: ActiveFollowOutputLayout,
|
||||
): boolean {
|
||||
return (
|
||||
previous.scrollContainer === next.scrollContainer &&
|
||||
previous.viewportWidth === next.viewportWidth &&
|
||||
previous.viewportHeight === next.viewportHeight &&
|
||||
previous.activationKey === next.activationKey &&
|
||||
previous.isActivationReady === next.isActivationReady &&
|
||||
previous.renderLiveAuxiliary === next.renderLiveAuxiliary &&
|
||||
previous.historyMounted === next.historyMounted &&
|
||||
previous.historyVirtualized === next.historyVirtualized &&
|
||||
previous.liveHead === next.liveHead &&
|
||||
previous.virtualTotalSize === next.virtualTotalSize
|
||||
);
|
||||
}
|
||||
|
||||
interface ObservedViewportGeometry {
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
scrollHeight: number;
|
||||
}
|
||||
|
||||
function getObservedViewportGeometry(scrollContainer: HTMLElement): ObservedViewportGeometry {
|
||||
return {
|
||||
clientWidth: scrollContainer.clientWidth,
|
||||
clientHeight: scrollContainer.clientHeight,
|
||||
scrollWidth: scrollContainer.scrollWidth,
|
||||
scrollHeight: scrollContainer.scrollHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function observedViewportGeometriesEqual(
|
||||
previous: ObservedViewportGeometry,
|
||||
next: ObservedViewportGeometry,
|
||||
): boolean {
|
||||
return (
|
||||
previous.clientWidth === next.clientWidth &&
|
||||
previous.clientHeight === next.clientHeight &&
|
||||
previous.scrollWidth === next.scrollWidth &&
|
||||
previous.scrollHeight === next.scrollHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getScrollContainerDistanceFromBottom(
|
||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
): number {
|
||||
@@ -275,6 +334,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
|
||||
const historyStartPrependAnchorActiveRef = useRef(false);
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
const lastActiveFollowOutputLayoutRef = useRef<ActiveFollowOutputLayout | null>(null);
|
||||
const lastObservedViewportGeometryRef = useRef<ObservedViewportGeometry | null>(null);
|
||||
const wasFollowOutputLayoutActiveRef = useRef(false);
|
||||
const resumedUnchangedLayoutRef = useRef(false);
|
||||
const pendingResumeGeometryCheckRef = useRef(false);
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
@@ -753,14 +817,39 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Following output is a layout invariant: rows, footer, and bottom offset must
|
||||
// reach the browser in the same paint.
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive || !followOutputRef.current) {
|
||||
const layout: ActiveFollowOutputLayout = {
|
||||
scrollContainer: scrollContainerRef.current,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
activationKey,
|
||||
isActivationReady,
|
||||
renderLiveAuxiliary,
|
||||
historyMounted: segments.historyMounted,
|
||||
historyVirtualized: segments.historyVirtualized,
|
||||
liveHead: segments.liveHead,
|
||||
virtualTotalSize,
|
||||
};
|
||||
const resumedUnchangedLayout = Boolean(
|
||||
isActive &&
|
||||
!wasFollowOutputLayoutActiveRef.current &&
|
||||
lastActiveFollowOutputLayoutRef.current &&
|
||||
activeFollowOutputLayoutsEqual(lastActiveFollowOutputLayoutRef.current, layout),
|
||||
);
|
||||
resumedUnchangedLayoutRef.current = resumedUnchangedLayout;
|
||||
pendingResumeGeometryCheckRef.current = resumedUnchangedLayout;
|
||||
wasFollowOutputLayoutActiveRef.current = isActive;
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
lastActiveFollowOutputLayoutRef.current = layout;
|
||||
if (!followOutputRef.current || resumedUnchangedLayout) return;
|
||||
cancelPendingStickToBottom();
|
||||
scrollMessagesToBottom("auto");
|
||||
}, [
|
||||
activationKey,
|
||||
cancelPendingStickToBottom,
|
||||
isActive,
|
||||
isActivationReady,
|
||||
renderLiveAuxiliary,
|
||||
scrollMessagesToBottom,
|
||||
segments.historyMounted,
|
||||
@@ -773,7 +862,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
updateScrollMetrics();
|
||||
const resumedUnchangedLayout = resumedUnchangedLayoutRef.current;
|
||||
resumedUnchangedLayoutRef.current = false;
|
||||
if (!resumedUnchangedLayout) {
|
||||
updateScrollMetrics();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
@@ -802,9 +895,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return;
|
||||
}
|
||||
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
if (!pendingResumeGeometryCheckRef.current) {
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextGeometry = getObservedViewportGeometry(scrollContainer);
|
||||
if (pendingResumeGeometryCheckRef.current) {
|
||||
pendingResumeGeometryCheckRef.current = false;
|
||||
reportReadingPosition();
|
||||
const previousGeometry = lastObservedViewportGeometryRef.current;
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
if (previousGeometry && observedViewportGeometriesEqual(previousGeometry, nextGeometry)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
if (historyStartPrependAnchorActiveRef.current) {
|
||||
applyHistoryStartPrependAnchor();
|
||||
}
|
||||
@@ -829,6 +936,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
applyHistoryStartPrependAnchor,
|
||||
evaluateHistoryStart,
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
scheduleStickToBottom,
|
||||
updateScrollMetrics,
|
||||
|
||||
@@ -13,14 +13,15 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
areWorkspaceSelectionListsEqual,
|
||||
areRetainedWorkspaceSelectionListsEqual,
|
||||
areWorkspaceSelectionsEqual,
|
||||
getNextWorkspaceDeckExpirationDelay,
|
||||
getWorkspaceSelectionKey,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
reconcileRetainedWorkspaceSelections,
|
||||
type RetainedWorkspaceSelection,
|
||||
resolveWorkspaceDeckEntries,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
import {
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
} from "@/utils/host-route-browser";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
@@ -199,29 +201,31 @@ function WorkspaceDeck({
|
||||
recoveryAgentId: string | null;
|
||||
}) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
|
||||
);
|
||||
const unmountWorkspaceSelection = useCallback((selection: ActiveWorkspaceSelection) => {
|
||||
setMountedSelections((current) =>
|
||||
current.filter(
|
||||
(mountedSelection) => !areWorkspaceSelectionsEqual(mountedSelection, selection),
|
||||
),
|
||||
setRetainedSelections((current) =>
|
||||
current.filter((entry) => !areWorkspaceSelectionsEqual(entry.selection, selection)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const nextMountedSelections = useMemo(
|
||||
const reconciliationNow = Date.now();
|
||||
const nextRetainedSelections = useMemo(
|
||||
() =>
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: retainedSelections,
|
||||
activeSelection,
|
||||
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
now: reconciliationNow,
|
||||
}),
|
||||
[activeSelection, mountedSelections],
|
||||
[activeSelection, reconciliationNow, retainedSelections],
|
||||
);
|
||||
const renderedSelections = useMemo(
|
||||
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections),
|
||||
[nextMountedSelections],
|
||||
() =>
|
||||
orderWorkspaceSelectionsForStableRender(
|
||||
nextRetainedSelections.map((entry) => entry.selection),
|
||||
),
|
||||
[nextRetainedSelections],
|
||||
);
|
||||
const renderedEntries = useMemo(
|
||||
() => resolveWorkspaceDeckEntries({ selections: renderedSelections, activeSelection }),
|
||||
@@ -229,26 +233,49 @@ function WorkspaceDeck({
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) {
|
||||
setMountedSelections(nextMountedSelections);
|
||||
if (!areRetainedWorkspaceSelectionListsEqual(retainedSelections, nextRetainedSelections)) {
|
||||
setRetainedSelections(nextRetainedSelections);
|
||||
}
|
||||
}, [mountedSelections, nextMountedSelections]);
|
||||
}, [nextRetainedSelections, retainedSelections]);
|
||||
|
||||
useEffect(() => {
|
||||
const expirationDelay = getNextWorkspaceDeckExpirationDelay({
|
||||
entries: nextRetainedSelections,
|
||||
activeSelection,
|
||||
now: reconciliationNow,
|
||||
});
|
||||
if (expirationDelay === null) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
setRetainedSelections((current) =>
|
||||
reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: current,
|
||||
activeSelection,
|
||||
now: Date.now(),
|
||||
}),
|
||||
);
|
||||
}, expirationDelay + 1);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [activeSelection, nextRetainedSelections, reconciliationNow]);
|
||||
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{renderedEntries.map(({ selection, active }) => {
|
||||
return (
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
selection={selection}
|
||||
active={active}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<RenderProfile id="WorkspaceDeck">
|
||||
<View style={styles.deck}>
|
||||
{renderedEntries.map(({ selection, active }) => {
|
||||
return (
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
selection={selection}
|
||||
active={active}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,11 @@ import { readMeasuredWidth } from "@/hooks/use-container-width";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
||||
import {
|
||||
useAgentControlCommandCenterActions,
|
||||
type AgentControlCommandCenterSource,
|
||||
} from "@/command-center/agent-control-registration";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
resolveComposerControlDensity,
|
||||
@@ -154,11 +158,28 @@ export interface DraftAgentControlsProps {
|
||||
interface AgentControlsProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isPaneFocused: boolean;
|
||||
onDropdownClose?: () => void;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
function AgentControlCommandCenterRegistration({
|
||||
sourceId,
|
||||
enabled,
|
||||
controls,
|
||||
}: {
|
||||
sourceId: string;
|
||||
enabled: boolean;
|
||||
controls: AgentControlCommandCenterSource;
|
||||
}) {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
useAgentControlCommandCenterActions({
|
||||
sourceId,
|
||||
enabled: enabled && isActiveComposer,
|
||||
controls,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function findOptionLabel(
|
||||
options: AgentControlOption[] | undefined,
|
||||
selectedId: string | undefined,
|
||||
@@ -1477,7 +1498,6 @@ function ThinkingComboboxOption({
|
||||
export const AgentControls = memo(function AgentControls({
|
||||
agentId,
|
||||
serverId,
|
||||
isPaneFocused,
|
||||
onDropdownClose,
|
||||
isCompactLayout,
|
||||
}: AgentControlsProps) {
|
||||
@@ -1649,10 +1669,8 @@ export const AgentControls = memo(function AgentControls({
|
||||
[agentId, agentProvider, client, toast, updatePreferences],
|
||||
);
|
||||
|
||||
useAgentControlCommandCenterActions({
|
||||
sourceId: `agent:${serverId}:${agentId}`,
|
||||
enabled: isPaneFocused && Boolean(client),
|
||||
controls: {
|
||||
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
|
||||
() => ({
|
||||
serverId,
|
||||
ownerKey: agentId,
|
||||
provider: agentProvider,
|
||||
@@ -1673,8 +1691,31 @@ export const AgentControls = memo(function AgentControls({
|
||||
list: agent?.features,
|
||||
set: handleSetFeature,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
[
|
||||
activeModelId,
|
||||
agent?.features,
|
||||
agentId,
|
||||
agentModelSelectorProviders,
|
||||
agentProvider,
|
||||
commandCenterModes,
|
||||
handleSelectCommandCenterModel,
|
||||
handleSelectThinkingOption,
|
||||
handleSetFeature,
|
||||
modeProviderDefinitions,
|
||||
modelSelection.selectedThinkingId,
|
||||
modelSelection.thinkingOptions,
|
||||
serverId,
|
||||
],
|
||||
);
|
||||
|
||||
const commandCenterRegistration = (
|
||||
<AgentControlCommandCenterRegistration
|
||||
sourceId={`agent:${serverId}:${agentId}`}
|
||||
enabled={Boolean(client)}
|
||||
controls={commandCenterControls}
|
||||
/>
|
||||
);
|
||||
|
||||
const handleModelSelectorOpen = useCallback(() => {
|
||||
refetchSnapshotIfStale(agentProvider);
|
||||
@@ -1692,30 +1733,33 @@ export const AgentControls = memo(function AgentControls({
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlledAgentControls
|
||||
provider={agent.provider}
|
||||
modelSelectorProviders={agentModelSelectorProviders}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={modelSelection.activeModelId ?? undefined}
|
||||
onSelectModel={handleSelectModel}
|
||||
agentProfiles={agentProfiles}
|
||||
onApplyAgentProfile={agentProfiles?.applyProfile}
|
||||
onEditAgentProfiles={handleEditAgentProfiles}
|
||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||
onSelectThinkingOption={handleSelectThinkingOption}
|
||||
features={agent.features}
|
||||
onSetFeature={handleSetFeature}
|
||||
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||
onModelSelectorOpen={handleModelSelectorOpen}
|
||||
onRetryModelProvider={handleRetryModelProvider}
|
||||
isRetryingModelProvider={snapshotIsRefreshing}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
modeControl={modeControl}
|
||||
modelSelectorServerId={serverId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
<>
|
||||
{commandCenterRegistration}
|
||||
<ControlledAgentControls
|
||||
provider={agent.provider}
|
||||
modelSelectorProviders={agentModelSelectorProviders}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={modelSelection.activeModelId ?? undefined}
|
||||
onSelectModel={handleSelectModel}
|
||||
agentProfiles={agentProfiles}
|
||||
onApplyAgentProfile={agentProfiles?.applyProfile}
|
||||
onEditAgentProfiles={handleEditAgentProfiles}
|
||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||
onSelectThinkingOption={handleSelectThinkingOption}
|
||||
features={agent.features}
|
||||
onSetFeature={handleSetFeature}
|
||||
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||
onModelSelectorOpen={handleModelSelectorOpen}
|
||||
onRetryModelProvider={handleRetryModelProvider}
|
||||
isRetryingModelProvider={snapshotIsRefreshing}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
modeControl={modeControl}
|
||||
modelSelectorServerId={serverId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
@@ -296,12 +296,11 @@ interface RenderLeftContentArgs {
|
||||
serverId: string;
|
||||
focusInput: () => void;
|
||||
isCompactLayout: boolean;
|
||||
isPaneFocused: boolean;
|
||||
showAgentControls: boolean;
|
||||
}
|
||||
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args;
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
|
||||
if (!args.showAgentControls) return null;
|
||||
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
|
||||
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
|
||||
@@ -310,7 +309,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
|
||||
<AgentControls
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onDropdownClose={focusInput}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
@@ -561,6 +559,68 @@ function dispatchComposerKeyboardAction(args: DispatchComposerKeyboardActionArgs
|
||||
return true;
|
||||
}
|
||||
|
||||
function ComposerKeyboardRegistration({
|
||||
messageInputRef,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
handleCancelAgent,
|
||||
focusMessageInputForKeyboardAction,
|
||||
isMessageInputFocused,
|
||||
handlerId,
|
||||
}: Omit<DispatchComposerKeyboardActionArgs, "action" | "isPaneFocused"> & {
|
||||
isMessageInputFocused: boolean;
|
||||
handlerId: string;
|
||||
}) {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean =>
|
||||
dispatchComposerKeyboardAction({
|
||||
action,
|
||||
isPaneFocused: isActiveComposer,
|
||||
messageInputRef,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
handleCancelAgent,
|
||||
focusMessageInputForKeyboardAction,
|
||||
}),
|
||||
[
|
||||
focusMessageInputForKeyboardAction,
|
||||
handleCancelAgent,
|
||||
isActiveComposer,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
messageInputRef,
|
||||
],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId,
|
||||
actions: [
|
||||
"agent.interrupt",
|
||||
"message-input.focus",
|
||||
"message-input.send",
|
||||
"message-input.dictation-toggle",
|
||||
"message-input.dictation-cancel",
|
||||
"message-input.dictation-confirm",
|
||||
"message-input.voice-toggle",
|
||||
"message-input.voice-mute-toggle",
|
||||
],
|
||||
enabled: isActiveComposer,
|
||||
priority: resolveKeyboardPriority(isMessageInputFocused),
|
||||
isActive: () => isActiveComposer,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function ComposerAutocomplete(props: React.ComponentProps<typeof AutocompletePopover>) {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
return <AutocompletePopover {...props} visible={isActiveComposer && props.visible} />;
|
||||
}
|
||||
|
||||
function resolveMessageInputPassthroughAction(
|
||||
actionId: string,
|
||||
): MessageInputKeyboardActionKind | null {
|
||||
@@ -1052,12 +1112,23 @@ function ComposerVoiceModeButton({
|
||||
);
|
||||
}
|
||||
|
||||
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<ComposerContent {...props} />
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type ComposerContentProps = Omit<ComposerProps, "isPaneFocused">;
|
||||
|
||||
const ComposerContent = memo(ComposerContentImpl);
|
||||
|
||||
// oxlint-disable-next-line complexity
|
||||
export function Composer({
|
||||
function ComposerContentImpl({
|
||||
agentId,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isPaneFocused,
|
||||
onSubmitMessage,
|
||||
onClientSlashCommand,
|
||||
hasExternalContent = false,
|
||||
@@ -1096,7 +1167,7 @@ export function Composer({
|
||||
readOnly = false,
|
||||
submitLabel,
|
||||
placeholder,
|
||||
}: ComposerProps) {
|
||||
}: ComposerContentProps) {
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
const { t } = useTranslation();
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
@@ -1682,46 +1753,6 @@ export function Composer({
|
||||
focusMessageInputWithPlatformStrategy(messageInputRef);
|
||||
}, []);
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean =>
|
||||
dispatchComposerKeyboardAction({
|
||||
action,
|
||||
isPaneFocused,
|
||||
messageInputRef,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
handleCancelAgent,
|
||||
focusMessageInputForKeyboardAction,
|
||||
}),
|
||||
[
|
||||
focusMessageInputForKeyboardAction,
|
||||
handleCancelAgent,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
isPaneFocused,
|
||||
],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: keyboardHandlerIdRef.current,
|
||||
actions: [
|
||||
"agent.interrupt",
|
||||
"message-input.focus",
|
||||
"message-input.send",
|
||||
"message-input.dictation-toggle",
|
||||
"message-input.dictation-cancel",
|
||||
"message-input.dictation-confirm",
|
||||
"message-input.voice-toggle",
|
||||
"message-input.voice-mute-toggle",
|
||||
],
|
||||
enabled: isPaneFocused,
|
||||
priority: resolveKeyboardPriority(isMessageInputFocused),
|
||||
isActive: () => isPaneFocused,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
enabled: !externalKeyboardShift,
|
||||
@@ -2032,18 +2063,9 @@ export function Composer({
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
isPaneFocused,
|
||||
showAgentControls: mode.showAgentControls,
|
||||
}),
|
||||
[
|
||||
agentControls,
|
||||
agentId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
isPaneFocused,
|
||||
mode.showAgentControls,
|
||||
serverId,
|
||||
],
|
||||
[agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -2169,10 +2191,20 @@ export function Composer({
|
||||
const githubEmptyText = githubSearchResultsQuery.isFetching
|
||||
? t("composer.github.searching")
|
||||
: t("composer.github.noResults");
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused && mode.showAutocomplete;
|
||||
const autocompleteVisible = autocomplete.isVisible && mode.showAutocomplete;
|
||||
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<>
|
||||
<ComposerKeyboardRegistration
|
||||
handlerId={keyboardHandlerIdRef.current}
|
||||
messageInputRef={messageInputRef}
|
||||
isAgentRunning={isAgentRunning}
|
||||
isCancellingAgent={isCancellingAgent}
|
||||
isConnected={isConnected}
|
||||
handleCancelAgent={handleCancelAgent}
|
||||
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
|
||||
isMessageInputFocused={isMessageInputFocused}
|
||||
/>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
@@ -2182,7 +2214,7 @@ export function Composer({
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
<ComposerAutocomplete
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
@@ -2220,7 +2252,6 @@ export function Composer({
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
@@ -2267,7 +2298,7 @@ export function Composer({
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { useComposerHeightMirror } from "./height-mirror";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||
@@ -116,8 +117,6 @@ export interface MessageInputProps {
|
||||
autoFocus?: boolean;
|
||||
autoFocusKey?: string;
|
||||
disabled?: boolean;
|
||||
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isPaneFocused?: boolean;
|
||||
/** Content to render on the left side of the composer toolbar (e.g., AgentControls) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||
@@ -496,11 +495,26 @@ function useAutoFocusOnWebEffect(
|
||||
const active = typeof document !== "undefined" ? document.activeElement : null;
|
||||
return Boolean(element) && active === element;
|
||||
},
|
||||
deferInitialAttempt: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoFocus, autoFocusKey]);
|
||||
}
|
||||
|
||||
function MessageInputAutoFocus({
|
||||
enabled,
|
||||
autoFocusKey,
|
||||
textInputRef,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
autoFocusKey: string | undefined;
|
||||
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
|
||||
}) {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
function MessageInputOverlay({
|
||||
showDictationOverlay,
|
||||
showRealtimeOverlay,
|
||||
@@ -580,7 +594,8 @@ function FocusHint({
|
||||
focusInputKeys: ShortcutChord | null | undefined;
|
||||
label: string;
|
||||
}) {
|
||||
if (!visible || !focusInputKeys || !label.trim()) return null;
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
|
||||
return (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
{label}
|
||||
@@ -1030,7 +1045,6 @@ interface ResolvedMessageInputProps {
|
||||
autoFocus: boolean;
|
||||
autoFocusKey: string | undefined;
|
||||
disabled: boolean;
|
||||
isPaneFocused: boolean;
|
||||
leftContent: React.ReactNode;
|
||||
beforeVoiceContent: React.ReactNode;
|
||||
rightContent: React.ReactNode;
|
||||
@@ -1078,7 +1092,6 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
autoFocus: props.autoFocus ?? false,
|
||||
autoFocusKey: props.autoFocusKey,
|
||||
disabled: props.disabled ?? false,
|
||||
isPaneFocused: props.isPaneFocused ?? true,
|
||||
leftContent: props.leftContent,
|
||||
beforeVoiceContent: props.beforeVoiceContent,
|
||||
rightContent: props.rightContent,
|
||||
@@ -1134,7 +1147,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
autoFocus,
|
||||
autoFocusKey,
|
||||
disabled,
|
||||
isPaneFocused,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
@@ -1237,8 +1249,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
};
|
||||
}, [onFocusChange]);
|
||||
|
||||
useAutoFocusOnWebEffect(textInputRef, autoFocus, autoFocusKey);
|
||||
|
||||
const handleDictationTranscript = useCallback(
|
||||
(text: string, _meta: { requestId: string }) => {
|
||||
const autoSend = sendAfterTranscriptRef.current;
|
||||
@@ -1738,6 +1748,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
return (
|
||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||
<MessageInputAutoFocus
|
||||
enabled={autoFocus}
|
||||
autoFocusKey={autoFocusKey}
|
||||
textInputRef={textInputRef}
|
||||
/>
|
||||
{/* Regular input */}
|
||||
<View
|
||||
ref={inputWrapperRef}
|
||||
@@ -1759,13 +1774,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onBlur={handleInputBlur}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
autoFocus={false}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onPasteImages={onPasteImages}
|
||||
onPasteError={handlePasteError}
|
||||
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusHintVisible={isWeb && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
focusHintLabel={t("composer.input.focusHint", {
|
||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("navigateToHostWorkspaceRoute", () => {
|
||||
it("pops to the mounted host route and targets the requested workspace", () => {
|
||||
const { navigationRef, dispatch } = createNavigationRef({
|
||||
key: "root-stack",
|
||||
index: 1,
|
||||
routeNames: ["index", "settings/[section]", "h/[serverId]"],
|
||||
routes: [
|
||||
{
|
||||
@@ -78,10 +79,35 @@ describe("navigateToHostWorkspaceRoute", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses route navigation when the mounted host route is already focused", () => {
|
||||
const { navigationRef, dispatch } = createNavigationRef({
|
||||
key: "root-stack",
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
key: "host-server-1",
|
||||
name: "h/[serverId]",
|
||||
params: { serverId: "server-1" },
|
||||
},
|
||||
],
|
||||
});
|
||||
registerWorkspaceRouteNavigationRef(navigationRef);
|
||||
const dismissTo = vi.fn();
|
||||
|
||||
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-b", { dismissTo });
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b");
|
||||
});
|
||||
|
||||
it("preserves a workspace open intent in the POP_TO target", () => {
|
||||
const { navigationRef, dispatch } = createNavigationRef({
|
||||
key: "root-stack",
|
||||
routes: [{ key: "host-server-1", name: "h/[serverId]" }],
|
||||
index: 1,
|
||||
routes: [
|
||||
{ key: "host-server-1", name: "h/[serverId]" },
|
||||
{ key: "new", name: "new" },
|
||||
],
|
||||
});
|
||||
registerWorkspaceRouteNavigationRef(navigationRef);
|
||||
|
||||
|
||||
@@ -31,12 +31,21 @@ export function registerWorkspaceRouteNavigationRef(
|
||||
};
|
||||
}
|
||||
|
||||
function findStackKeyWithMountedRouteName(state: unknown, routeName: string): string | null {
|
||||
interface MountedRouteStack {
|
||||
key: string;
|
||||
focusedRouteName: string | null;
|
||||
}
|
||||
|
||||
function findStackWithMountedRouteName(
|
||||
state: unknown,
|
||||
routeName: string,
|
||||
): MountedRouteStack | null {
|
||||
if (!state || typeof state !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = state as {
|
||||
index?: unknown;
|
||||
key?: unknown;
|
||||
routes?: unknown;
|
||||
};
|
||||
@@ -52,19 +61,34 @@ function findStackKeyWithMountedRouteName(state: unknown, routeName: string): st
|
||||
!!route && typeof route === "object" && (route as { name?: unknown }).name === routeName,
|
||||
)
|
||||
) {
|
||||
return candidate.key;
|
||||
const focusedIndex =
|
||||
typeof candidate.index === "number" &&
|
||||
Number.isInteger(candidate.index) &&
|
||||
candidate.index >= 0 &&
|
||||
candidate.index < candidate.routes.length
|
||||
? candidate.index
|
||||
: candidate.routes.length - 1;
|
||||
const focusedRoute = candidate.routes[focusedIndex];
|
||||
const focusedRouteName =
|
||||
focusedRoute && typeof focusedRoute === "object"
|
||||
? (focusedRoute as { name?: unknown }).name
|
||||
: null;
|
||||
return {
|
||||
key: candidate.key,
|
||||
focusedRouteName: typeof focusedRouteName === "string" ? focusedRouteName : null,
|
||||
};
|
||||
}
|
||||
|
||||
for (const route of candidate.routes) {
|
||||
if (!route || typeof route !== "object") {
|
||||
continue;
|
||||
}
|
||||
const childKey = findStackKeyWithMountedRouteName(
|
||||
const childStack = findStackWithMountedRouteName(
|
||||
(route as { state?: unknown }).state,
|
||||
routeName,
|
||||
);
|
||||
if (childKey) {
|
||||
return childKey;
|
||||
if (childStack) {
|
||||
return childStack;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,15 +103,15 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
|
||||
}
|
||||
|
||||
const rootState = navigation.getRootState();
|
||||
const target = findStackKeyWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME);
|
||||
if (!target) {
|
||||
const hostStack = findStackWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME);
|
||||
if (!hostStack || hostStack.focusedRouteName === ROOT_HOST_ROUTE_NAME) {
|
||||
return false;
|
||||
}
|
||||
const open = getHostWorkspaceOpenParamFromPathname(route);
|
||||
|
||||
const action: NavigationAction = {
|
||||
type: "POP_TO",
|
||||
target,
|
||||
target: hostStack.key,
|
||||
payload: {
|
||||
name: ROOT_HOST_ROUTE_NAME,
|
||||
params: {
|
||||
|
||||
@@ -1648,7 +1648,7 @@ function ActiveAgentComposer({
|
||||
onChangeAttachments={agentInputDraft.setAttachments}
|
||||
cwd={cwd}
|
||||
clearDraft={agentInputDraft.clear}
|
||||
autoFocus={isPaneFocused}
|
||||
autoFocus
|
||||
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
getNextWorkspaceDeckExpirationDelay,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
reconcileRetainedWorkspaceSelections,
|
||||
resolveWorkspaceDeckEntries,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
|
||||
function workspace(workspaceId: string, serverId = "server"): ActiveWorkspaceSelection {
|
||||
@@ -15,65 +18,115 @@ function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
|
||||
return selections.map((selection) => selection.workspaceId);
|
||||
}
|
||||
|
||||
describe("pruneMountedWorkspaceSelections", () => {
|
||||
it("retains the deck while an app-wide route temporarily clears the active workspace", () => {
|
||||
const mountedSelections = [workspace("A"), workspace("B")];
|
||||
function retained(workspaceId: string, inactiveSince: number | null) {
|
||||
return { selection: workspace(workspaceId), inactiveSince };
|
||||
}
|
||||
|
||||
function retainedWorkspaceIds(
|
||||
entries: ReturnType<typeof reconcileRetainedWorkspaceSelections>,
|
||||
): string[] {
|
||||
return entries.map((entry) => entry.selection.workspaceId);
|
||||
}
|
||||
|
||||
describe("reconcileRetainedWorkspaceSelections", () => {
|
||||
it("retains inactive workspaces for ten minutes across app-wide routes", () => {
|
||||
const retainedEntries = [retained("A", 1_000), retained("B", 2_000)];
|
||||
|
||||
expect(
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: retainedEntries,
|
||||
activeSelection: null,
|
||||
now: 2_000 + WORKSPACE_DECK_INACTIVE_TTL_MS - 1,
|
||||
}),
|
||||
).toBe(mountedSelections);
|
||||
).toEqual([retained("B", 2_000)]);
|
||||
});
|
||||
|
||||
it("keeps the active workspace and the two most recent inactive workspaces", () => {
|
||||
const mountedAfterA = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [],
|
||||
activeSelection: workspace("A"),
|
||||
it("keeps the active workspace and nine most recently activated inactive workspaces", () => {
|
||||
let retainedEntries = Array.from(
|
||||
{ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES },
|
||||
(_, index) => retained(String(index + 1), index + 1),
|
||||
).toReversed();
|
||||
|
||||
retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: retainedEntries,
|
||||
activeSelection: workspace("11"),
|
||||
now: 11,
|
||||
});
|
||||
const mountedAfterB = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterA,
|
||||
|
||||
expect(retainedWorkspaceIds(retainedEntries)).toEqual([
|
||||
"11",
|
||||
"10",
|
||||
"9",
|
||||
"8",
|
||||
"7",
|
||||
"6",
|
||||
"5",
|
||||
"4",
|
||||
"3",
|
||||
"2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts the TTL when the active workspace becomes inactive", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("A", null), retained("B", 2_000)],
|
||||
activeSelection: workspace("B"),
|
||||
});
|
||||
const mountedAfterC = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterB,
|
||||
activeSelection: workspace("C"),
|
||||
});
|
||||
const mountedAfterD = pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedAfterC,
|
||||
activeSelection: workspace("D"),
|
||||
now: 3_000,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedAfterD)).toEqual(["D", "C", "B"]);
|
||||
expect(retainedEntries).toEqual([retained("B", null), retained("A", 3_000)]);
|
||||
});
|
||||
|
||||
it("retains the active workspace", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("A")],
|
||||
it("never expires the active workspace", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("A", 0)],
|
||||
activeSelection: workspace("A"),
|
||||
now: WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A"]);
|
||||
expect(retainedEntries).toEqual([retained("A", null)]);
|
||||
});
|
||||
|
||||
it("expires an inactive workspace at the TTL boundary", () => {
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("B", null), retained("A", 0)],
|
||||
activeSelection: workspace("B"),
|
||||
now: WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
});
|
||||
|
||||
expect(retainedEntries).toEqual([retained("B", null)]);
|
||||
});
|
||||
|
||||
it("deduplicates retained workspace selections", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("B"), workspace("A"), workspace("B")],
|
||||
const retainedEntries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("B", 2), retained("A", 1), retained("B", 0)],
|
||||
activeSelection: workspace("A"),
|
||||
now: 3,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A", "B"]);
|
||||
expect(retainedWorkspaceIds(retainedEntries)).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextWorkspaceDeckExpirationDelay", () => {
|
||||
it("returns the remaining lifetime of the oldest inactive workspace", () => {
|
||||
expect(
|
||||
getNextWorkspaceDeckExpirationDelay({
|
||||
entries: [retained("C", null), retained("B", 2_000), retained("A", 1_000)],
|
||||
activeSelection: workspace("C"),
|
||||
now: 4_000,
|
||||
}),
|
||||
).toBe(WORKSPACE_DECK_INACTIVE_TTL_MS - 3_000);
|
||||
});
|
||||
|
||||
it("always allows at least the active workspace", () => {
|
||||
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [workspace("A"), workspace("B")],
|
||||
activeSelection: workspace("C"),
|
||||
maxMountedWorkspaces: 0,
|
||||
});
|
||||
|
||||
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["C"]);
|
||||
it("does not schedule expiration for the active workspace", () => {
|
||||
expect(
|
||||
getNextWorkspaceDeckExpirationDelay({
|
||||
entries: [retained("A", null)],
|
||||
activeSelection: workspace("A"),
|
||||
now: 4_000,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 3;
|
||||
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 10;
|
||||
export const WORKSPACE_DECK_INACTIVE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
interface PruneMountedWorkspaceSelectionsInput {
|
||||
currentSelections: ActiveWorkspaceSelection[];
|
||||
export interface RetainedWorkspaceSelection {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
inactiveSince: number | null;
|
||||
}
|
||||
|
||||
interface ReconcileRetainedWorkspaceSelectionsInput {
|
||||
currentEntries: RetainedWorkspaceSelection[];
|
||||
activeSelection: ActiveWorkspaceSelection | null;
|
||||
now: number;
|
||||
maxMountedWorkspaces?: number;
|
||||
inactiveTtlMs?: number;
|
||||
}
|
||||
|
||||
interface GetNextWorkspaceDeckExpirationDelayInput {
|
||||
entries: RetainedWorkspaceSelection[];
|
||||
activeSelection: ActiveWorkspaceSelection | null;
|
||||
now: number;
|
||||
inactiveTtlMs?: number;
|
||||
}
|
||||
|
||||
interface WorkspaceDeckEntryMountInput {
|
||||
@@ -30,53 +45,92 @@ export function areWorkspaceSelectionsEqual(
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
export function areWorkspaceSelectionListsEqual(
|
||||
left: ActiveWorkspaceSelection[],
|
||||
right: ActiveWorkspaceSelection[],
|
||||
export function areRetainedWorkspaceSelectionListsEqual(
|
||||
left: RetainedWorkspaceSelection[],
|
||||
right: RetainedWorkspaceSelection[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every((selection, index) =>
|
||||
areWorkspaceSelectionsEqual(selection, right[index] ?? null),
|
||||
);
|
||||
return left.every((entry, index) => {
|
||||
const otherEntry = right[index];
|
||||
return (
|
||||
otherEntry !== undefined &&
|
||||
entry.inactiveSince === otherEntry.inactiveSince &&
|
||||
areWorkspaceSelectionsEqual(entry.selection, otherEntry.selection)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function pruneMountedWorkspaceSelections({
|
||||
currentSelections,
|
||||
export function reconcileRetainedWorkspaceSelections({
|
||||
currentEntries,
|
||||
activeSelection,
|
||||
now,
|
||||
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
|
||||
if (!activeSelection) {
|
||||
return currentSelections;
|
||||
}
|
||||
|
||||
const maxSelections = Math.max(1, maxMountedWorkspaces);
|
||||
const nextSelections: ActiveWorkspaceSelection[] = [];
|
||||
inactiveTtlMs = WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
}: ReconcileRetainedWorkspaceSelectionsInput): RetainedWorkspaceSelection[] {
|
||||
const maxSelections = activeSelection
|
||||
? Math.max(1, maxMountedWorkspaces)
|
||||
: Math.max(0, maxMountedWorkspaces);
|
||||
const nextEntries: RetainedWorkspaceSelection[] = [];
|
||||
const seenSelectionKeys = new Set<string>();
|
||||
|
||||
function appendSelection(selection: ActiveWorkspaceSelection): void {
|
||||
if (nextSelections.length >= maxSelections) {
|
||||
function appendEntry(entry: RetainedWorkspaceSelection): void {
|
||||
if (nextEntries.length >= maxSelections) {
|
||||
return;
|
||||
}
|
||||
const selectionKey = getWorkspaceSelectionKey(selection);
|
||||
const selectionKey = getWorkspaceSelectionKey(entry.selection);
|
||||
if (seenSelectionKeys.has(selectionKey)) {
|
||||
return;
|
||||
}
|
||||
seenSelectionKeys.add(selectionKey);
|
||||
nextSelections.push(selection);
|
||||
nextEntries.push(entry);
|
||||
}
|
||||
|
||||
appendSelection(activeSelection);
|
||||
if (activeSelection) {
|
||||
appendEntry({ selection: activeSelection, inactiveSince: null });
|
||||
}
|
||||
|
||||
for (const selection of currentSelections) {
|
||||
if (areWorkspaceSelectionsEqual(selection, activeSelection)) {
|
||||
for (const entry of currentEntries) {
|
||||
if (areWorkspaceSelectionsEqual(entry.selection, activeSelection)) {
|
||||
continue;
|
||||
}
|
||||
appendSelection(selection);
|
||||
const inactiveSince = entry.inactiveSince ?? now;
|
||||
if (now - inactiveSince >= inactiveTtlMs) {
|
||||
continue;
|
||||
}
|
||||
appendEntry({ selection: entry.selection, inactiveSince });
|
||||
}
|
||||
|
||||
return nextSelections;
|
||||
if (areRetainedWorkspaceSelectionListsEqual(currentEntries, nextEntries)) {
|
||||
return currentEntries;
|
||||
}
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function getNextWorkspaceDeckExpirationDelay({
|
||||
entries,
|
||||
activeSelection,
|
||||
now,
|
||||
inactiveTtlMs = WORKSPACE_DECK_INACTIVE_TTL_MS,
|
||||
}: GetNextWorkspaceDeckExpirationDelayInput): number | null {
|
||||
let nextExpirationDelay: number | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
areWorkspaceSelectionsEqual(entry.selection, activeSelection) ||
|
||||
entry.inactiveSince === null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const expirationDelay = Math.max(0, entry.inactiveSince + inactiveTtlMs - now);
|
||||
nextExpirationDelay =
|
||||
nextExpirationDelay === null
|
||||
? expirationDelay
|
||||
: Math.min(nextExpirationDelay, expirationDelay);
|
||||
}
|
||||
|
||||
return nextExpirationDelay;
|
||||
}
|
||||
|
||||
export function orderWorkspaceSelectionsForStableRender(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import {
|
||||
composeWorkspaceStructure,
|
||||
createWorkspaceStructureProjectsSelector,
|
||||
selectHasHydratedWorkspaces,
|
||||
selectHydratedWorkspaceServerIds,
|
||||
selectWorkspaceDirectoryServerIds,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceOrderByScope,
|
||||
selectWorkspaceStatusesForBadges,
|
||||
selectWorkspaceStructureProjects,
|
||||
workspaceEqualityFns,
|
||||
type WorkspaceStructure,
|
||||
} from "./selectors";
|
||||
@@ -100,9 +100,13 @@ export function useWorkspaceDirectory(
|
||||
}
|
||||
|
||||
export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure {
|
||||
const selectProjects = useMemo(
|
||||
() => createWorkspaceStructureProjectsSelector(serverIds),
|
||||
[serverIds],
|
||||
);
|
||||
const projects = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceStructureProjects(state, serverIds),
|
||||
selectProjects,
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
const projectOrder = useStoreWithEqualityFn(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createProjectViewKey } from "@/projects/workspace-structure";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
composeWorkspaceStructure,
|
||||
createWorkspaceStructureProjectsSelector,
|
||||
selectHasWorkspaces,
|
||||
selectHydratedWorkspaceServerIds,
|
||||
selectWorkspaceDirectoryServerIds,
|
||||
@@ -338,6 +339,27 @@ describe("selectWorkspaceFields", () => {
|
||||
});
|
||||
|
||||
describe("workspace structure composition", () => {
|
||||
it("reuses structure when unrelated session state changes", () => {
|
||||
const workspace = createWorkspace({ id: "workspace-a" });
|
||||
const workspaces = new Map([[workspace.id, workspace]]);
|
||||
const projects = new Map([
|
||||
[workspace.projectId, projectDescriptorFromTestWorkspace(workspace)],
|
||||
]);
|
||||
const selectProjects = createWorkspaceStructureProjectsSelector([SERVER_ID]);
|
||||
const before = selectProjects({ sessions: { [SERVER_ID]: { workspaces, projects } } });
|
||||
const after = selectProjects({
|
||||
sessions: {
|
||||
[SERVER_ID]: {
|
||||
workspaces,
|
||||
projects,
|
||||
hasHydratedWorkspaces: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
function snapshotStructure(
|
||||
serverId: string,
|
||||
sidebar: SidebarOrderSnapshot,
|
||||
|
||||
@@ -182,6 +182,38 @@ export function selectWorkspaceStructureProjects(
|
||||
return buildWorkspaceStructureProjects({ sessions });
|
||||
}
|
||||
|
||||
export function createWorkspaceStructureProjectsSelector(
|
||||
serverIds: readonly string[],
|
||||
): (state: SessionsSnapshot) => WorkspaceStructureProject[] {
|
||||
let previousInputs: Array<{
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
projects: Map<string, ProjectDescriptor> | undefined;
|
||||
}> | null = null;
|
||||
let previousProjects: WorkspaceStructureProject[] | null = null;
|
||||
|
||||
return (state) => {
|
||||
const inputs = serverIds.map((serverId) => ({
|
||||
workspaces: state.sessions[serverId]?.workspaces,
|
||||
projects: state.sessions[serverId]?.projects,
|
||||
}));
|
||||
const priorInputs = previousInputs;
|
||||
const unchanged =
|
||||
priorInputs !== null &&
|
||||
inputs.every(
|
||||
(input, index) =>
|
||||
input.workspaces === priorInputs[index]?.workspaces &&
|
||||
input.projects === priorInputs[index]?.projects,
|
||||
);
|
||||
if (unchanged && previousProjects) {
|
||||
return previousProjects;
|
||||
}
|
||||
|
||||
previousInputs = inputs;
|
||||
previousProjects = selectWorkspaceStructureProjects(state, serverIds);
|
||||
return previousProjects;
|
||||
};
|
||||
}
|
||||
|
||||
export function selectProject(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
|
||||
@@ -52,6 +52,36 @@ describe("focusWithRetries", () => {
|
||||
expect(frameQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can defer the first focus attempt until the next animation frame", () => {
|
||||
const focus = vi.fn();
|
||||
|
||||
focusWithRetries({
|
||||
focus,
|
||||
isFocused: () => true,
|
||||
deferInitialAttempt: true,
|
||||
});
|
||||
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
|
||||
flushAnimationFrames(1);
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels a deferred focus before its first attempt", () => {
|
||||
const focus = vi.fn();
|
||||
const cancel = focusWithRetries({
|
||||
focus,
|
||||
isFocused: () => false,
|
||||
deferInitialAttempt: true,
|
||||
});
|
||||
|
||||
cancel();
|
||||
flushAnimationFrames(1);
|
||||
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps retrying on later animation frames until focus succeeds", () => {
|
||||
let focused = false;
|
||||
let attempts = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
interface FocusWithRetriesOptions {
|
||||
focus: () => void;
|
||||
isFocused: () => boolean;
|
||||
deferInitialAttempt?: boolean;
|
||||
timeoutMs?: number;
|
||||
onSuccess?: () => void;
|
||||
onTimeout?: () => void;
|
||||
@@ -9,6 +10,7 @@ interface FocusWithRetriesOptions {
|
||||
export function focusWithRetries({
|
||||
focus,
|
||||
isFocused,
|
||||
deferInitialAttempt = false,
|
||||
timeoutMs = 1500,
|
||||
onSuccess,
|
||||
onTimeout,
|
||||
@@ -40,7 +42,11 @@ export function focusWithRetries({
|
||||
});
|
||||
};
|
||||
|
||||
tick();
|
||||
if (deferInitialAttempt) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
tick();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
Reference in New Issue
Block a user