mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
2 Commits
ops/relay-
...
tooltip-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603b380321 | ||
|
|
ac45b2f2ae |
@@ -84,10 +84,6 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||
- Never define components inside other components. Module-scope only.
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
||||
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
|
||||
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
@@ -80,22 +80,6 @@ definition, no longer eligible to begin.
|
||||
has caused native crashes.
|
||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||
prop commit from resurrecting a closed overlay.
|
||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||
same active signal without adding another layout root. Persistent animations, timers, polling, and
|
||||
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
|
||||
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
|
||||
while its retained panel is active so hidden animated styles remain mounted without receiving clock
|
||||
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
|
||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -90,19 +90,6 @@ When a reusable component has a prop whose whole job is dynamic geometry, make t
|
||||
|
||||
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
||||
|
||||
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
|
||||
|
||||
```tsx
|
||||
// Wrong: sharedStyles.row already carries Unistyles metadata.
|
||||
const styles = StyleSheet.create({ row: sharedStyles.row });
|
||||
<View style={styles.row} />;
|
||||
|
||||
// Right: one registered style identity reaches the native view.
|
||||
<View style={sharedStyles.row} />;
|
||||
```
|
||||
|
||||
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
@@ -37,91 +37,6 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the active Markdown root mounted across streamed text updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-markdown-root-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for Markdown root stability test.",
|
||||
});
|
||||
try {
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
|
||||
|
||||
const activeBlock = assistantMessage.locator(":scope > *").last();
|
||||
const initialText = (await activeBlock.textContent()) ?? "";
|
||||
const activeBlockHandle = await activeBlock.elementHandle();
|
||||
if (!activeBlockHandle) {
|
||||
throw new Error("Expected the active assistant message to contain a block");
|
||||
}
|
||||
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
|
||||
if (!markdownRoot) {
|
||||
throw new Error("Expected the active assistant block to contain a Markdown root");
|
||||
}
|
||||
|
||||
await page.evaluate((block) => {
|
||||
const evidence = {
|
||||
addedNodes: 0,
|
||||
characterDataMutations: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
evidence.addedNodes += record.addedNodes.length;
|
||||
evidence.removedNodes += record.removedNodes.length;
|
||||
if (record.type === "characterData") {
|
||||
evidence.characterDataMutations += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||
Object.assign(window, {
|
||||
__markdownRootEvidence: evidence,
|
||||
__markdownRootObserver: observer,
|
||||
});
|
||||
}, activeBlockHandle);
|
||||
|
||||
await expect
|
||||
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
|
||||
.toBeGreaterThan(initialText.length + 80);
|
||||
|
||||
const evidence = await page.evaluate((root) => {
|
||||
const state = window as typeof window & {
|
||||
__markdownRootEvidence?: {
|
||||
addedNodes: number;
|
||||
characterDataMutations: number;
|
||||
removedNodes: number;
|
||||
};
|
||||
__markdownRootObserver?: MutationObserver;
|
||||
};
|
||||
state.__markdownRootObserver?.disconnect();
|
||||
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
|
||||
const message = messages.item(messages.length - 1);
|
||||
const block = message?.lastElementChild;
|
||||
return {
|
||||
...state.__markdownRootEvidence,
|
||||
connected: root.isConnected,
|
||||
sameRoot: block?.firstElementChild === root,
|
||||
};
|
||||
}, markdownRoot);
|
||||
|
||||
await testInfo.attach("markdown-root-stability", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(evidence.connected).toBe(true);
|
||||
expect(evidence.sameRoot).toBe(true);
|
||||
expect(
|
||||
evidence.removedNodes,
|
||||
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -152,7 +152,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
@@ -99,7 +98,6 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
}: {
|
||||
inFlightTurnStartedAt?: Date | null;
|
||||
}) {
|
||||
const active = useRetainedPanelActive();
|
||||
return (
|
||||
<View style={stylesheet.turnFooterContent}>
|
||||
<View style={stylesheet.workingLoader}>
|
||||
@@ -108,7 +106,6 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
{inFlightTurnStartedAt ? (
|
||||
<LiveElapsed
|
||||
startedAt={inFlightTurnStartedAt}
|
||||
active={active}
|
||||
style={stylesheet.workingElapsed}
|
||||
testID="turn-working-elapsed"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
@@ -87,7 +88,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
@@ -508,7 +509,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isActive = useContext(MountedTabActiveContext);
|
||||
const frozenStreamItemsRef = useRef(streamItems);
|
||||
const frozenStreamHeadRef = useRef(streamHead);
|
||||
if (isActive) {
|
||||
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
@@ -451,16 +451,9 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout ? sidebarChrome : null}
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && <LeftSidebar />}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
@@ -475,7 +468,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar />}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
@@ -484,6 +477,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<HostChooserModal />
|
||||
<ProjectPickerModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<QuittingOverlay />
|
||||
@@ -496,26 +490,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
surface
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function SidebarChrome({
|
||||
showSidebar,
|
||||
keyboardShortcutsEnabled,
|
||||
}: {
|
||||
showSidebar: boolean;
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
return (
|
||||
<SidebarModelProvider active={showSidebar && isOpen}>
|
||||
{showSidebar ? <LeftSidebar /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
);
|
||||
return <SidebarModelProvider>{content}</SidebarModelProvider>;
|
||||
}
|
||||
|
||||
function MobileGestureWrapper({
|
||||
@@ -529,9 +504,7 @@ function MobileGestureWrapper({
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill}>
|
||||
{children}
|
||||
</View>
|
||||
{children}
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
useActiveWorkspaceSelection,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
areWorkspaceSelectionListsEqual,
|
||||
areWorkspaceSelectionsEqual,
|
||||
getWorkspaceSelectionKey,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
@@ -187,25 +185,22 @@ function WorkspaceDeck() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const nextMountedSelections = useMemo(
|
||||
() =>
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
const next = pruneMountedWorkspaceSelections({
|
||||
currentSelections: current,
|
||||
activeSelection,
|
||||
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
}),
|
||||
[activeSelection, mountedSelections],
|
||||
);
|
||||
const renderedSelections = useMemo(
|
||||
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections),
|
||||
[nextMountedSelections],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) {
|
||||
setMountedSelections(nextMountedSelections);
|
||||
}
|
||||
}, [mountedSelections, nextMountedSelections]);
|
||||
});
|
||||
if (areWorkspaceSelectionListsEqual(current, next)) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [activeSelection]);
|
||||
|
||||
if (!activeSelection) {
|
||||
return null;
|
||||
@@ -213,7 +208,7 @@ function WorkspaceDeck() {
|
||||
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{renderedSelections.map((selection) => {
|
||||
{mountedSelections.map((selection) => {
|
||||
return (
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
@@ -256,8 +251,8 @@ function WorkspaceDeckEntry({
|
||||
}
|
||||
|
||||
return (
|
||||
<RetainedPanel
|
||||
active={isActive}
|
||||
<View
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
@@ -265,7 +260,7 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,4 +268,11 @@ const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
},
|
||||
activeDeckEntry: {
|
||||
flex: 1,
|
||||
},
|
||||
inactiveDeckEntry: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
@@ -38,7 +39,7 @@ export function AssistantMarkdownLink({
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
|
||||
@@ -147,38 +148,6 @@ export function AssistantMarkdownCodeLink({
|
||||
);
|
||||
}
|
||||
|
||||
function formatInlinePathTargetForTooltip(
|
||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
||||
if (!workspaceRoot) {
|
||||
return filePath;
|
||||
}
|
||||
const root = workspaceRoot.replace(/\/+$/, "");
|
||||
if (!root) {
|
||||
return filePath;
|
||||
}
|
||||
if (filePath === root) {
|
||||
return ".";
|
||||
}
|
||||
const prefix = `${root}/`;
|
||||
if (filePath.startsWith(prefix)) {
|
||||
return filePath.slice(prefix.length);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
|
||||
41
packages/app/src/assistant-file-links/tooltip-path.test.ts
Normal file
41
packages/app/src/assistant-file-links/tooltip-path.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
|
||||
describe("formatFileLinkTooltipPath", () => {
|
||||
it("shows a Windows file path relative to its workspace regardless of separators", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: {
|
||||
path: "C:/Users/me/repo/src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
},
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("src/app.ts:12-20");
|
||||
});
|
||||
|
||||
it("shows the workspace root as a dot", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/repo" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe(".");
|
||||
});
|
||||
|
||||
it("keeps an absolute path outside the workspace", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/notes.md" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe("/Users/me/notes.md");
|
||||
});
|
||||
|
||||
it("keeps the target path when the workspace root is unavailable", () => {
|
||||
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
|
||||
"src/app.ts:12",
|
||||
);
|
||||
});
|
||||
});
|
||||
35
packages/app/src/assistant-file-links/tooltip-path.ts
Normal file
35
packages/app/src/assistant-file-links/tooltip-path.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
interface FormatFileLinkTooltipPathInput {
|
||||
target: WorkspaceFileLocation;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export function formatFileLinkTooltipPath({
|
||||
target,
|
||||
workspaceRoot,
|
||||
}: FormatFileLinkTooltipPathInput): string {
|
||||
const normalizedTargetPath = normalizeWorkspacePath(target.path);
|
||||
const normalizedWorkspaceRoot = normalizeWorkspacePath(workspaceRoot);
|
||||
let isWorkspaceRoot = false;
|
||||
if (normalizedTargetPath && normalizedWorkspaceRoot) {
|
||||
isWorkspaceRoot = normalizedTargetPath === normalizedWorkspaceRoot;
|
||||
if (/^[A-Za-z]:\//.test(normalizedTargetPath)) {
|
||||
isWorkspaceRoot =
|
||||
normalizedTargetPath.toLowerCase() === normalizedWorkspaceRoot.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedPaths = workspaceRoot
|
||||
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
|
||||
: null;
|
||||
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
|
||||
@@ -122,26 +121,24 @@ export function CompactExplorerSidebar({
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarStyle}
|
||||
>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
</RetainedPanelActivity>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarStyle}
|
||||
>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import React, { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/ut
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
|
||||
@@ -414,7 +414,7 @@ export function FilePane({
|
||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
||||
// gate itself lives in isFileQueryEnabled.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isActive = useContext(MountedTabActiveContext);
|
||||
const isAppVisible = useAppVisible();
|
||||
|
||||
const query = useQuery({
|
||||
|
||||
@@ -36,12 +36,8 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { type SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -79,7 +75,6 @@ interface SidebarSharedProps {
|
||||
theme: SidebarTheme;
|
||||
statusGroups: StatusGroup[];
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
@@ -137,7 +132,6 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
|
||||
const {
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -241,7 +235,6 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
theme,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -257,39 +250,35 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -544,7 +533,6 @@ function MobileSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -656,7 +644,6 @@ function MobileSidebar({
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -684,7 +671,6 @@ function DesktopSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -810,7 +796,6 @@ function DesktopSidebar({
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
|
||||
@@ -692,39 +692,33 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
interface LiveElapsedProps {
|
||||
startedAt: Date;
|
||||
active?: boolean;
|
||||
style?: StyleProp<TextStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks every second to render an elapsed duration. Isolated from parents so
|
||||
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
|
||||
* only this component re-renders on each tick.
|
||||
*/
|
||||
export const LiveElapsed = memo(function LiveElapsed({
|
||||
startedAt,
|
||||
active = true,
|
||||
style,
|
||||
testID,
|
||||
}: LiveElapsedProps) {
|
||||
const startedAtMs = startedAt.getTime();
|
||||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
|
||||
const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||
const handle = setInterval(() => {
|
||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||
}, 1000);
|
||||
}, 100);
|
||||
return () => clearInterval(handle);
|
||||
}, [active, startedAtMs]);
|
||||
}, [startedAtMs]);
|
||||
|
||||
return (
|
||||
<Text style={style} testID={testID}>
|
||||
{formatDuration(visibleElapsedMs)}
|
||||
{formatDuration(elapsedMs)}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,16 +9,22 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
function MarkdownInlineText({
|
||||
textKey,
|
||||
inheritedStyle,
|
||||
ruleStyle,
|
||||
children,
|
||||
}: {
|
||||
textKey: string;
|
||||
inheritedStyle: StyleProp<TextStyle>;
|
||||
ruleStyle: StyleProp<TextStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
|
||||
return <Text style={style}>{children}</Text>;
|
||||
return (
|
||||
<Text key={textKey} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({
|
||||
@@ -33,10 +39,12 @@ function MarkdownListItemContent({
|
||||
}
|
||||
|
||||
function MarkdownParagraph({
|
||||
textKey,
|
||||
paragraphStyle,
|
||||
isLastChild,
|
||||
children,
|
||||
}: {
|
||||
textKey: string;
|
||||
paragraphStyle: StyleProp<ViewStyle>;
|
||||
isLastChild: boolean;
|
||||
children: ReactNode;
|
||||
@@ -45,7 +53,11 @@ function MarkdownParagraph({
|
||||
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
|
||||
[paragraphStyle, isLastChild],
|
||||
);
|
||||
return <View style={style}>{children}</View>;
|
||||
return (
|
||||
<View key={textKey} style={style}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function createPlanMarkdownRules() {
|
||||
@@ -57,7 +69,11 @@ function createPlanMarkdownRules() {
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.text}>
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
@@ -69,7 +85,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.textgroup}
|
||||
>
|
||||
@@ -84,7 +100,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.code_block}
|
||||
>
|
||||
@@ -98,7 +114,11 @@ function createPlanMarkdownRules() {
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.fence}>
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.fence}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
@@ -110,7 +130,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.code_inline}
|
||||
>
|
||||
@@ -163,7 +183,7 @@ function createPlanMarkdownRules() {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<MarkdownParagraph
|
||||
key={node.key}
|
||||
textKey={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
isLastChild={isLastChild}
|
||||
>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { createContext, memo, type ReactNode, useContext } from "react";
|
||||
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
const RetainedPanelActiveContext = createContext(true);
|
||||
|
||||
export function useRetainedPanelActive(): boolean {
|
||||
return useContext(RetainedPanelActiveContext);
|
||||
}
|
||||
|
||||
interface RetainedPanelProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
interface RetainedPanelActivityProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RetainedPanelActivity({ active, children }: RetainedPanelActivityProps) {
|
||||
const parentActive = useRetainedPanelActive();
|
||||
return (
|
||||
<RetainedPanelActiveContext value={parentActive && active}>
|
||||
{children}
|
||||
</RetainedPanelActiveContext>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps expensive panel state mounted without letting an inactive panel render
|
||||
* on screen. The stable, non-collapsible native root is intentional: retained
|
||||
* panels must not detach or reparent their native descendants when visibility
|
||||
* changes.
|
||||
*/
|
||||
export const RetainedPanel = memo(function RetainedPanel({
|
||||
active,
|
||||
children,
|
||||
style,
|
||||
testID,
|
||||
}: RetainedPanelProps) {
|
||||
const visibleStyle = StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(styles.root, style);
|
||||
const panelStyle = active
|
||||
? visibleStyle
|
||||
: StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(visibleStyle, styles.hidden);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={active}>
|
||||
<View
|
||||
collapsable={false}
|
||||
pointerEvents={active ? "auto" : "none"}
|
||||
style={panelStyle}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
hidden: {
|
||||
display: "none",
|
||||
},
|
||||
});
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
shouldShowSidebarHostLabels,
|
||||
useSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacePlacement,
|
||||
@@ -222,7 +223,6 @@ function selectionForSelectedWorkspace(
|
||||
interface SidebarWorkspaceListProps {
|
||||
statusGroups: StatusGroup[];
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onToggleProjectCollapsed: (projectKey: string) => void;
|
||||
@@ -1613,7 +1613,6 @@ function WorkspaceRowWithMenu({
|
||||
|
||||
interface WorkspaceRowItemProps {
|
||||
workspace: SidebarWorkspacePlacement;
|
||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||
subtitle?: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -1629,7 +1628,6 @@ interface WorkspaceRowItemProps {
|
||||
|
||||
function WorkspaceRowItem({
|
||||
workspace,
|
||||
workspaceEntry,
|
||||
subtitle,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -1652,7 +1650,7 @@ function WorkspaceRowItem({
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
workspaceEntry={workspaceEntry}
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -1690,7 +1688,6 @@ function areWorkspaceRowItemPropsEqual(
|
||||
});
|
||||
return (
|
||||
previous.workspace === next.workspace &&
|
||||
previous.workspaceEntry === next.workspaceEntry &&
|
||||
previous.subtitle === next.subtitle &&
|
||||
previous.shortcutNumber === next.shortcutNumber &&
|
||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||
@@ -1707,7 +1704,7 @@ function areWorkspaceRowItemPropsEqual(
|
||||
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
|
||||
|
||||
function WorkspaceRow({
|
||||
workspaceEntry,
|
||||
workspace,
|
||||
subtitle,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -1719,7 +1716,7 @@ function WorkspaceRow({
|
||||
isCreating = false,
|
||||
selected,
|
||||
}: {
|
||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||
workspace: SidebarWorkspacePlacement;
|
||||
subtitle?: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -1731,13 +1728,15 @@ function WorkspaceRow({
|
||||
isCreating?: boolean;
|
||||
selected: boolean;
|
||||
}) {
|
||||
if (!workspaceEntry) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
|
||||
if (!hydratedWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspaceEntry}
|
||||
workspace={hydratedWorkspace}
|
||||
subtitle={subtitle}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -1754,7 +1753,6 @@ function WorkspaceRow({
|
||||
|
||||
function ProjectBlock({
|
||||
project,
|
||||
workspaceEntriesByKey,
|
||||
collapsed,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
@@ -1777,7 +1775,6 @@ function ProjectBlock({
|
||||
supportsMultiplicityByServerId,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
collapsed: boolean;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
@@ -1827,7 +1824,6 @@ function ProjectBlock({
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
workspace={item}
|
||||
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||
subtitle={
|
||||
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
|
||||
}
|
||||
@@ -1854,7 +1850,6 @@ function ProjectBlock({
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2009,7 +2004,6 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
return (
|
||||
previous.project === next.project &&
|
||||
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
|
||||
previous.collapsed === next.collapsed &&
|
||||
previous.displayName === next.displayName &&
|
||||
previous.iconDataUri === next.iconDataUri &&
|
||||
@@ -2064,7 +2058,6 @@ const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||
export function SidebarWorkspaceList({
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
@@ -2103,7 +2096,6 @@ export function SidebarWorkspaceList({
|
||||
) : (
|
||||
<ProjectModeList
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
@@ -2153,7 +2145,6 @@ function SidebarStatusModeWrapper({
|
||||
|
||||
function ProjectModeList({
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -2342,7 +2333,6 @@ function ProjectModeList({
|
||||
return (
|
||||
<MemoProjectBlock
|
||||
project={item}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
||||
displayName={item.projectName}
|
||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||
@@ -2381,7 +2371,6 @@ function ProjectModeList({
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
workspaceEntriesByKey,
|
||||
creatingWorkspaceIds,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useSidebarWorkspacesList,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacesListResult,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
|
||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
||||
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
} from "@/utils/sidebar-shortcuts";
|
||||
|
||||
interface SidebarModel extends SidebarWorkspacesListResult {
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
groupMode: SidebarGroupMode;
|
||||
statusGroups: StatusGroup[];
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
@@ -25,13 +23,7 @@ interface SidebarModel extends SidebarWorkspacesListResult {
|
||||
|
||||
const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||
|
||||
export function SidebarModelProvider({
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
export function SidebarModelProvider({ children }: { children: ReactNode }) {
|
||||
const list = useSidebarWorkspacesList();
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||
@@ -44,16 +36,14 @@ export function SidebarModelProvider({
|
||||
(state) => state.toggleProjectCollapsed,
|
||||
);
|
||||
const isStatusMode = groupMode === "status";
|
||||
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
|
||||
list.workspacePlacements,
|
||||
active !== false || isStatusMode,
|
||||
);
|
||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||
placements: list.workspacePlacements,
|
||||
enabled: isStatusMode,
|
||||
});
|
||||
const statusGroups = useMemo(
|
||||
() =>
|
||||
isStatusMode
|
||||
? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey)
|
||||
: [],
|
||||
[isStatusMode, list.projectNamesByKey, workspaceEntriesByKey],
|
||||
isStatusMode ? buildStatusGroups(statusWorkspacePlacements, list.projectNamesByKey) : [],
|
||||
[isStatusMode, list.projectNamesByKey, statusWorkspacePlacements],
|
||||
);
|
||||
const shortcutModel = useMemo(() => {
|
||||
if (isStatusMode) {
|
||||
@@ -67,22 +57,13 @@ export function SidebarModelProvider({
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...list,
|
||||
workspaceEntriesByKey,
|
||||
groupMode,
|
||||
statusGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
shortcutModel,
|
||||
}),
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
groupMode,
|
||||
list,
|
||||
shortcutModel,
|
||||
statusGroups,
|
||||
toggleProjectCollapsed,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
[collapsedProjectKeys, groupMode, list, shortcutModel, statusGroups, toggleProjectCollapsed],
|
||||
);
|
||||
|
||||
return <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
|
||||
|
||||
@@ -4,7 +4,11 @@ import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } fr
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import {
|
||||
useSidebarWorkspaceEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -282,12 +286,13 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
showShortcutBadge,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
workspace: SidebarStatusWorkspacePlacement;
|
||||
subtitle: string;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||
@@ -299,9 +304,11 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
if (!workspaceEntry) return null;
|
||||
|
||||
return (
|
||||
<StatusWorkspaceRowWithMenu
|
||||
workspace={workspace}
|
||||
workspace={workspaceEntry}
|
||||
subtitle={subtitle}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
|
||||
@@ -142,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
</Text>
|
||||
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
|
||||
</View>
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
<View style={styles.workspaceRowRight}>{children}</View>
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
|
||||
@@ -502,6 +502,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
|
||||
shortcutBadgeOverlay: {
|
||||
position: "absolute",
|
||||
top: 1,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Fragment,
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -31,7 +32,6 @@ import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -74,6 +74,10 @@ import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
// true = this tab slot is the active (visible) tab; false = mounted but hidden.
|
||||
// Defaults to true so consumers outside a slot (e.g. web preview) are unaffected.
|
||||
export const MountedTabActiveContext = createContext<boolean>(true);
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
workspaceKey: string;
|
||||
@@ -210,20 +214,26 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
const wrapperStyle = useMemo(() => {
|
||||
const display: "flex" | "none" = isVisible ? "flex" : "none";
|
||||
return { display, flex: 1 };
|
||||
}, [isVisible]);
|
||||
const handleFocusPane = useCallback(() => {
|
||||
onFocusPane(paneId);
|
||||
}, [onFocusPane, paneId]);
|
||||
|
||||
return (
|
||||
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||
<RetainedPanel active={isVisible}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
<MountedTabActiveContext value={isVisible}>
|
||||
<View style={wrapperStyle}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</View>
|
||||
</MountedTabActiveContext>
|
||||
</RenderProfile>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getSyncedLoaderDotOpacity, getSyncedLoaderStep } from "./synced-loader-state";
|
||||
|
||||
describe("synced loader state", () => {
|
||||
test("advances through six wall-clock-aligned steps every 950 milliseconds", () => {
|
||||
const sampleTimes = [0, 158, 159, 316, 317, 474, 475, 633, 634, 791, 792, 949, 950];
|
||||
|
||||
const steps = sampleTimes.map(getSyncedLoaderStep);
|
||||
|
||||
expect(steps).toEqual([0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 0]);
|
||||
});
|
||||
|
||||
test("preserves the six visible snake states", () => {
|
||||
const states: number[][] = [];
|
||||
for (let step = 0; step < 6; step += 1) {
|
||||
const dotOpacities: number[] = [];
|
||||
for (let dot = 0; dot < 6; dot += 1) {
|
||||
dotOpacities.push(getSyncedLoaderDotOpacity(step, dot));
|
||||
}
|
||||
states.push(dotOpacities);
|
||||
}
|
||||
|
||||
expect(states).toEqual([
|
||||
[1, 0, 0.78, 0, 0.56, 0.34],
|
||||
[0.78, 1, 0.56, 0, 0.34, 0],
|
||||
[0.56, 0.78, 0.34, 1, 0, 0],
|
||||
[0.34, 0.56, 0, 0.78, 0, 1],
|
||||
[0, 0.34, 0, 0.56, 1, 0.78],
|
||||
[0, 0, 1, 0.34, 0.78, 0.56],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
const SYNCED_LOADER_DURATION_MS = 950;
|
||||
export const SYNCED_LOADER_DOT_COUNT = 6;
|
||||
|
||||
const SYNCED_LOADER_OPACITY_STATES = [
|
||||
[1, 0, 0.78, 0, 0.56, 0.34],
|
||||
[0.78, 1, 0.56, 0, 0.34, 0],
|
||||
[0.56, 0.78, 0.34, 1, 0, 0],
|
||||
[0.34, 0.56, 0, 0.78, 0, 1],
|
||||
[0, 0.34, 0, 0.56, 1, 0.78],
|
||||
[0, 0, 1, 0.34, 0.78, 0.56],
|
||||
] as const;
|
||||
|
||||
export function getSyncedLoaderStep(nowMs: number): number {
|
||||
"worklet";
|
||||
const elapsedMs = nowMs % SYNCED_LOADER_DURATION_MS;
|
||||
return Math.floor((elapsedMs * SYNCED_LOADER_DOT_COUNT) / SYNCED_LOADER_DURATION_MS);
|
||||
}
|
||||
|
||||
export function getSyncedLoaderDotOpacity(step: number, dot: number): number {
|
||||
"worklet";
|
||||
return SYNCED_LOADER_OPACITY_STATES[step]?.[dot] ?? 0;
|
||||
}
|
||||
@@ -1,101 +1,65 @@
|
||||
import { useLayoutEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
makeMutable,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnUI } from "react-native-worklets";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
SYNCED_LOADER_DOT_COUNT,
|
||||
getSyncedLoaderDotOpacity,
|
||||
getSyncedLoaderStep,
|
||||
} from "@/components/synced-loader-state";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
const SYNCED_LOADER_DURATION_MS = 950;
|
||||
const SYNCED_LOADER_EPOCH_MS = 0;
|
||||
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
|
||||
const DOT_COUNT = DOT_SEQUENCE.length;
|
||||
const GRID_COLUMNS = 2;
|
||||
const DOT_KEYS = Array.from({ length: SYNCED_LOADER_DOT_COUNT }, (_, i) => `dot-${i}`);
|
||||
const sharedStep = makeMutable(0);
|
||||
const activeLoaderCount = makeMutable(0);
|
||||
const clockRunning = makeMutable(false);
|
||||
let nextStepListenerId = 1;
|
||||
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
|
||||
const DOT_KEYS = Array.from({ length: DOT_COUNT }, (_, i) => `dot-${i}`);
|
||||
const sharedStepProgress = makeMutable(0);
|
||||
let sharedLoopStarted = false;
|
||||
|
||||
function advanceSharedStep(): void {
|
||||
"worklet";
|
||||
if (activeLoaderCount.value === 0) {
|
||||
clockRunning.value = false;
|
||||
function ensureSharedStepLoopStarted(): void {
|
||||
if (sharedLoopStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = getSyncedLoaderStep(Date.now());
|
||||
if (sharedStep.value !== nextStep) {
|
||||
sharedStep.value = nextStep;
|
||||
}
|
||||
requestAnimationFrame(advanceSharedStep);
|
||||
}
|
||||
|
||||
function registerStepListener(
|
||||
step: SharedValue<number>,
|
||||
registered: SharedValue<boolean>,
|
||||
listenerId: number,
|
||||
): void {
|
||||
"worklet";
|
||||
if (registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
step.value = getSyncedLoaderStep(Date.now());
|
||||
sharedStep.addListener(listenerId, (nextStep) => {
|
||||
step.value = nextStep;
|
||||
});
|
||||
activeLoaderCount.value += 1;
|
||||
|
||||
if (!clockRunning.value) {
|
||||
clockRunning.value = true;
|
||||
sharedStep.value = step.value;
|
||||
requestAnimationFrame(advanceSharedStep);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterStepListener(registered: SharedValue<boolean>, listenerId: number): void {
|
||||
"worklet";
|
||||
if (!registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = false;
|
||||
sharedStep.removeListener(listenerId);
|
||||
activeLoaderCount.value -= 1;
|
||||
}
|
||||
|
||||
function useSyncedLoaderStep(active: boolean, reduceMotion: boolean): SharedValue<number> {
|
||||
// The local value lets retained loaders detach from the app-wide clock without
|
||||
// unmounting their animated views or leaving hidden style worklets subscribed.
|
||||
const step = useSharedValue(reduceMotion ? 0 : getSyncedLoaderStep(Date.now()));
|
||||
const registered = useSharedValue(false);
|
||||
const [listenerId] = useState(() => nextStepListenerId++);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active || reduceMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleOnUI(registerStepListener, step, registered, listenerId);
|
||||
return () => {
|
||||
scheduleOnUI(unregisterStepListener, registered, listenerId);
|
||||
};
|
||||
}, [active, listenerId, reduceMotion, registered, step]);
|
||||
|
||||
return step;
|
||||
sharedLoopStarted = true;
|
||||
const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
|
||||
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
|
||||
sharedStepProgress.value = withTiming(
|
||||
DOT_COUNT,
|
||||
{
|
||||
duration: Math.max(1, Math.round(SYNCED_LOADER_DURATION_MS - elapsedMs)),
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (!finished) {
|
||||
sharedLoopStarted = false;
|
||||
return;
|
||||
}
|
||||
sharedStepProgress.value = 0;
|
||||
sharedStepProgress.value = withRepeat(
|
||||
withTiming(DOT_COUNT, {
|
||||
duration: SYNCED_LOADER_DURATION_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
|
||||
const active = useRetainedPanelActive();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const step = useSyncedLoaderStep(active, reduceMotion);
|
||||
useEffect(() => {
|
||||
ensureSharedStepLoopStarted();
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: 1,
|
||||
}));
|
||||
|
||||
const gap = Math.max(1, Math.round(size * 0.12));
|
||||
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
|
||||
@@ -103,9 +67,10 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
||||
const gridHeight = dotSize * 3 + gap * 2;
|
||||
|
||||
const gridStyle = useMemo(
|
||||
() => ({ width: gridWidth, height: gridHeight }),
|
||||
[gridHeight, gridWidth],
|
||||
() => [animatedStyle, { width: gridWidth, height: gridHeight }],
|
||||
[animatedStyle, gridWidth, gridHeight],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
@@ -119,24 +84,25 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={gridStyle}>
|
||||
{Array.from({ length: SYNCED_LOADER_DOT_COUNT }).map((_, dotIndex) => {
|
||||
<Animated.View style={gridStyle}>
|
||||
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
|
||||
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
|
||||
const columnIndex = dotIndex % GRID_COLUMNS;
|
||||
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
|
||||
|
||||
return (
|
||||
<SpinnerDot
|
||||
key={DOT_KEYS[dotIndex]}
|
||||
color={color}
|
||||
dotSize={dotSize}
|
||||
dotIndex={dotIndex}
|
||||
step={step}
|
||||
sequenceIndex={sequenceIndex}
|
||||
progress={sharedStepProgress}
|
||||
left={columnIndex * (dotSize + gap)}
|
||||
top={rowIndex * (dotSize + gap)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -144,21 +110,35 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
||||
function SpinnerDot({
|
||||
color,
|
||||
dotSize,
|
||||
dotIndex,
|
||||
step,
|
||||
sequenceIndex,
|
||||
progress,
|
||||
left,
|
||||
top,
|
||||
}: {
|
||||
color: string;
|
||||
dotSize: number;
|
||||
dotIndex: number;
|
||||
step: SharedValue<number>;
|
||||
sequenceIndex: number;
|
||||
progress: SharedValue<number>;
|
||||
left: number;
|
||||
top: number;
|
||||
}) {
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: getSyncedLoaderDotOpacity(step.value, dotIndex),
|
||||
}));
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const headIndex = Math.floor(progress.value) % DOT_COUNT;
|
||||
let opacity = 0;
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) {
|
||||
const activeSequenceIndex =
|
||||
(headIndex + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT;
|
||||
if (sequenceIndex === activeSequenceIndex) {
|
||||
opacity = SNAKE_OPACITIES[segmentIndex] ?? 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
|
||||
const dotStyle = useMemo(
|
||||
() => [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
|
||||
import type { SidebarStatusWorkspacePlacement } from "@/hooks/sidebar-workspaces-view-model";
|
||||
|
||||
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
|
||||
export type StatusBucket = SidebarStatusWorkspacePlacement["statusBucket"];
|
||||
|
||||
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
|
||||
"needs_input",
|
||||
@@ -21,14 +21,14 @@ export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
|
||||
export interface StatusGroup {
|
||||
bucket: StatusBucket;
|
||||
label: string;
|
||||
rows: SidebarWorkspaceEntry[];
|
||||
rows: SidebarStatusWorkspacePlacement[];
|
||||
}
|
||||
|
||||
export function buildStatusGroups(
|
||||
workspaces: SidebarWorkspaceEntry[],
|
||||
workspaces: SidebarStatusWorkspacePlacement[],
|
||||
projectNamesByKey: Map<string, string>,
|
||||
): StatusGroup[] {
|
||||
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
|
||||
const bucketRows = new Map<StatusBucket, SidebarStatusWorkspacePlacement[]>();
|
||||
|
||||
for (const ws of workspaces) {
|
||||
const bucket: StatusBucket = ws.statusBucket;
|
||||
@@ -54,8 +54,8 @@ export function buildStatusGroups(
|
||||
}
|
||||
|
||||
function compareStatusRows(
|
||||
a: SidebarWorkspaceEntry,
|
||||
b: SidebarWorkspaceEntry,
|
||||
a: SidebarStatusWorkspacePlacement,
|
||||
b: SidebarStatusWorkspacePlacement,
|
||||
projectNamesByKey: Map<string, string>,
|
||||
): number {
|
||||
const aTime = a.statusEnteredAt?.getTime() ?? null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarWorkspaceEntries,
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
buildSidebarProjectsFromStructure,
|
||||
computeSidebarOrderUpdates,
|
||||
@@ -226,7 +226,7 @@ describe("shared sidebar workspace model", () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
const workspaceEntries = buildSidebarWorkspaceEntries({
|
||||
const statusRows = buildSidebarStatusWorkspacePlacements({
|
||||
placements: model.workspaces,
|
||||
sessions: [
|
||||
{
|
||||
@@ -290,66 +290,14 @@ describe("shared sidebar workspace model", () => {
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
Array.from(workspaceEntries.values()).map((entry) => [
|
||||
entry.workspaceKey,
|
||||
entry.statusBucket,
|
||||
entry.name,
|
||||
]),
|
||||
).toEqual([
|
||||
["host-a:main", "done", "main"],
|
||||
["host-b:feature", "running", "feature/status-flow"],
|
||||
]);
|
||||
expect(statusRows.map((entry) => [entry.workspaceKey, entry.statusBucket, entry.name])).toEqual(
|
||||
[
|
||||
["host-a:main", "done", "main"],
|
||||
["host-b:feature", "running", "feature/status-flow"],
|
||||
],
|
||||
);
|
||||
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
|
||||
});
|
||||
|
||||
it("preserves unchanged row identities when another workspace updates", () => {
|
||||
const model = buildSidebarWorkspacePlacementModel({
|
||||
projects: [project({ projectKey: "project", workspaceKeys: ["srv:one", "srv:two"] })],
|
||||
});
|
||||
const one = workspace({
|
||||
id: "one",
|
||||
name: "one",
|
||||
projectId: "project",
|
||||
projectDisplayName: "project",
|
||||
});
|
||||
const two = workspace({
|
||||
id: "two",
|
||||
name: "two",
|
||||
projectId: "project",
|
||||
projectDisplayName: "project",
|
||||
});
|
||||
const previousEntries = buildSidebarWorkspaceEntries({
|
||||
placements: model.workspaces,
|
||||
sessions: [
|
||||
{
|
||||
serverId: "srv",
|
||||
workspaceAgentActivity: new Map(),
|
||||
workspaces: new Map([
|
||||
["one", one],
|
||||
["two", two],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
const nextEntries = buildSidebarWorkspaceEntries({
|
||||
placements: model.workspaces,
|
||||
sessions: [
|
||||
{
|
||||
serverId: "srv",
|
||||
workspaceAgentActivity: new Map(),
|
||||
workspaces: new Map([
|
||||
["one", one],
|
||||
["two", { ...two, status: "running" }],
|
||||
]),
|
||||
},
|
||||
],
|
||||
previousEntries,
|
||||
});
|
||||
|
||||
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
|
||||
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowSidebarHostLabels", () => {
|
||||
|
||||
@@ -63,59 +63,12 @@ export interface SidebarWorkspacePlacementModel {
|
||||
projectNamesByKey: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface SidebarWorkspaceSession {
|
||||
export interface SidebarStatusWorkspaceSession {
|
||||
serverId: string;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceSessionSource {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
}
|
||||
|
||||
export function selectSidebarWorkspaceSessions(
|
||||
sessions: Record<string, SidebarWorkspaceSessionSource | undefined>,
|
||||
serverIds: readonly string[],
|
||||
): SidebarWorkspaceSession[] {
|
||||
const selected: SidebarWorkspaceSession[] = [];
|
||||
for (const serverId of serverIds) {
|
||||
const session = sessions[serverId];
|
||||
if (!session) {
|
||||
continue;
|
||||
}
|
||||
selected.push({
|
||||
serverId,
|
||||
workspaces: session.workspaces,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function areSidebarWorkspaceSessionsEqual(
|
||||
left: readonly SidebarWorkspaceSession[],
|
||||
right: readonly SidebarWorkspaceSession[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftSession = left[index];
|
||||
const rightSession = right[index];
|
||||
if (
|
||||
!leftSession ||
|
||||
!rightSession ||
|
||||
leftSession.serverId !== rightSession.serverId ||
|
||||
leftSession.workspaces !== rightSession.workspaces ||
|
||||
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface EffectiveWorkspaceStatus {
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
@@ -292,18 +245,17 @@ function resolveStructuralWorkspaceIdentity(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarWorkspaceEntries(input: {
|
||||
export function buildSidebarStatusWorkspacePlacements(input: {
|
||||
placements: readonly SidebarWorkspacePlacement[];
|
||||
sessions: SidebarWorkspaceSession[];
|
||||
sessions: SidebarStatusWorkspaceSession[];
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
}): Map<string, SidebarWorkspaceEntry> {
|
||||
}): SidebarStatusWorkspacePlacement[] {
|
||||
if (input.placements.length === 0 || input.sessions.length === 0) {
|
||||
return new Map();
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
|
||||
const entries = new Map<string, SidebarWorkspaceEntry>();
|
||||
const rows: SidebarStatusWorkspacePlacement[] = [];
|
||||
|
||||
for (const placement of input.placements) {
|
||||
const session = sessionByServerId.get(placement.serverId);
|
||||
@@ -315,46 +267,24 @@ export function buildSidebarWorkspaceEntries(input: {
|
||||
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
|
||||
if (!workspace) continue;
|
||||
|
||||
const entry = createSidebarWorkspaceEntry({
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus({
|
||||
serverId: placement.serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
const previousEntry = input.previousEntries?.get(placement.workspaceKey);
|
||||
entries.set(
|
||||
placement.workspaceKey,
|
||||
previousEntry && areSidebarWorkspaceEntriesEqual(previousEntry, entry)
|
||||
? previousEntry
|
||||
: entry,
|
||||
);
|
||||
|
||||
rows.push({
|
||||
...placement,
|
||||
name: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
statusBucket: effectiveStatus.status,
|
||||
statusEnteredAt: effectiveStatus.enteredAt,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function areSidebarWorkspaceEntriesEqual(
|
||||
left: SidebarWorkspaceEntry,
|
||||
right: SidebarWorkspaceEntry,
|
||||
): boolean {
|
||||
const keys = Object.keys(left) as Array<keyof SidebarWorkspaceEntry>;
|
||||
if (keys.length !== Object.keys(right).length) return false;
|
||||
return keys.every((key) => {
|
||||
if (key !== "prHint") return Object.is(left[key], right[key]);
|
||||
const leftHint = left.prHint;
|
||||
const rightHint = right.prHint;
|
||||
return (
|
||||
leftHint === rightHint ||
|
||||
(leftHint !== null &&
|
||||
rightHint !== null &&
|
||||
leftHint.url === rightHint.url &&
|
||||
leftHint.number === rightHint.number &&
|
||||
leftHint.state === rightHint.state &&
|
||||
leftHint.checks === rightHint.checks &&
|
||||
leftHint.checksStatus === rightHint.checksStatus &&
|
||||
leftHint.reviewDecision === rightHint.reviewDecision)
|
||||
);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromStructure(input: {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||
import {
|
||||
areSidebarWorkspaceSessionsEqual,
|
||||
selectSidebarWorkspaceSessions,
|
||||
type SidebarWorkspaceSession,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function activityMap(): Map<string, WorkspaceAgentActivity> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function sidebarSession(input?: Partial<Omit<SidebarWorkspaceSession, "serverId">>) {
|
||||
return {
|
||||
workspaces: input?.workspaces ?? workspaceMap(),
|
||||
workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar workspace session selection", () => {
|
||||
it("selects only sessions needed by sidebar placements", () => {
|
||||
const hostA = sidebarSession();
|
||||
const hostB = sidebarSession();
|
||||
const unusedHost = sidebarSession();
|
||||
|
||||
expect(
|
||||
selectSidebarWorkspaceSessions(
|
||||
{
|
||||
"host-a": hostA,
|
||||
"host-b": hostB,
|
||||
unused: unusedHost,
|
||||
},
|
||||
["host-b", "missing", "host-a"],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
serverId: "host-b",
|
||||
workspaces: hostB.workspaces,
|
||||
workspaceAgentActivity: hostB.workspaceAgentActivity,
|
||||
},
|
||||
{
|
||||
serverId: "host-a",
|
||||
workspaces: hostA.workspaces,
|
||||
workspaceAgentActivity: hostA.workspaceAgentActivity,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores high-frequency session changes outside the sidebar indexes", () => {
|
||||
const workspaces = workspaceMap();
|
||||
const workspaceAgentActivity = activityMap();
|
||||
|
||||
const previous = selectSidebarWorkspaceSessions(
|
||||
{ "host-a": sidebarSession({ workspaces, workspaceAgentActivity }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectSidebarWorkspaceSessions(
|
||||
{ "host-a": sidebarSession({ workspaces, workspaceAgentActivity }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
expect(previous).not.toBe(next);
|
||||
expect(areSidebarWorkspaceSessionsEqual(previous, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects changes to a selected workspace or activity index", () => {
|
||||
const workspaceAgentActivity = activityMap();
|
||||
const previous = selectSidebarWorkspaceSessions(
|
||||
{ "host-a": sidebarSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectSidebarWorkspaceSessions(
|
||||
{ "host-a": sidebarSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
expect(areSidebarWorkspaceSessionsEqual(previous, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
areSidebarWorkspaceSessionsEqual,
|
||||
buildSidebarWorkspaceEntries,
|
||||
selectSidebarWorkspaceSessions,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacePlacement,
|
||||
type SidebarWorkspaceSession,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
const EMPTY_ENTRIES = new Map<string, SidebarWorkspaceEntry>();
|
||||
const EMPTY_SESSIONS: SidebarWorkspaceSession[] = [];
|
||||
const EMPTY_PENDING_CREATE_ATTEMPTS: Record<string, never> = {};
|
||||
|
||||
export function useSidebarWorkspaceEntries(
|
||||
placements: readonly SidebarWorkspacePlacement[],
|
||||
enabled = true,
|
||||
): ReadonlyMap<string, SidebarWorkspaceEntry> {
|
||||
const serverIds = useMemo(
|
||||
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
|
||||
[placements],
|
||||
);
|
||||
const sessions = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
enabled ? selectSidebarWorkspaceSessions(state.sessions, serverIds) : EMPTY_SESSIONS,
|
||||
areSidebarWorkspaceSessionsEqual,
|
||||
);
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
||||
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
|
||||
);
|
||||
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
|
||||
|
||||
// Collection ownership is intentional: retained sidebars have one cheap
|
||||
// subscription to structurally shared indexes, never one session-store
|
||||
// subscription per mounted row.
|
||||
return useMemo(() => {
|
||||
if (!enabled || placements.length === 0 || sessions.length === 0) {
|
||||
previousEntriesRef.current = EMPTY_ENTRIES;
|
||||
return EMPTY_ENTRIES;
|
||||
}
|
||||
const entries = buildSidebarWorkspaceEntries({
|
||||
placements,
|
||||
sessions,
|
||||
pendingCreateAttempts,
|
||||
previousEntries: previousEntriesRef.current,
|
||||
});
|
||||
previousEntriesRef.current = entries;
|
||||
return entries;
|
||||
}, [enabled, pendingCreateAttempts, placements, sessions]);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -11,6 +14,7 @@ import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-works
|
||||
import {
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
computeSidebarOrderUpdates,
|
||||
createSidebarWorkspaceEntry,
|
||||
deriveSidebarLoadingState,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
@@ -22,9 +26,10 @@ export {
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromHostProjects,
|
||||
buildSidebarProjectsFromStructure,
|
||||
createSidebarWorkspaceEntry,
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
computeSidebarOrderUpdates,
|
||||
createSidebarWorkspaceEntry,
|
||||
deriveSidebarLoadingState,
|
||||
shouldShowSidebarHostLabels,
|
||||
type SidebarLoadingState,
|
||||
@@ -37,6 +42,39 @@ export {
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
export function useSidebarWorkspaceEntry(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): SidebarWorkspaceEntry | null {
|
||||
// Deep-compare so that adding/removing unrelated pending creates doesn't re-render this row.
|
||||
const pendingCreateAttempts = useStoreWithEqualityFn(
|
||||
useCreateFlowStore,
|
||||
(state) => state.pendingByDraftId,
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
|
||||
// Single subscription: reads workspace + agents together, computes the full entry, and
|
||||
// deep-compares the output. Agents-Map identity churn (setAgents replaces the Map on every
|
||||
// status transition) never causes a React re-render unless the derived entry actually changes.
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
const workspace = selectWorkspace(state, serverId, workspaceId);
|
||||
if (!workspace) return null;
|
||||
const workspaceAgentActivity = serverId
|
||||
? state.sessions[serverId]?.workspaceAgentActivity
|
||||
: undefined;
|
||||
return createSidebarWorkspaceEntry({
|
||||
serverId: serverId ?? "",
|
||||
workspace,
|
||||
pendingCreateAttempts,
|
||||
workspaceAgentActivity,
|
||||
});
|
||||
},
|
||||
equal,
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
|
||||
|
||||
84
packages/app/src/hooks/use-status-mode-workspaces.test.ts
Normal file
84
packages/app/src/hooks/use-status-mode-workspaces.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
areStatusModeSessionsEqual,
|
||||
selectStatusModeSessions,
|
||||
type StatusModeSession,
|
||||
} from "./use-status-mode-workspaces";
|
||||
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function activityMap(): Map<string, WorkspaceAgentActivity> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
|
||||
return {
|
||||
workspaces: input?.workspaces ?? workspaceMap(),
|
||||
workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("status mode session selection", () => {
|
||||
it("selects only sessions needed by visible placements", () => {
|
||||
const hostA = statusSession();
|
||||
const hostB = statusSession();
|
||||
const unusedHost = statusSession();
|
||||
|
||||
expect(
|
||||
selectStatusModeSessions(
|
||||
{
|
||||
"host-a": hostA,
|
||||
"host-b": hostB,
|
||||
unused: unusedHost,
|
||||
},
|
||||
["host-b", "missing", "host-a"],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
serverId: "host-b",
|
||||
workspaces: hostB.workspaces,
|
||||
workspaceAgentActivity: hostB.workspaceAgentActivity,
|
||||
},
|
||||
{
|
||||
serverId: "host-a",
|
||||
workspaces: hostA.workspaces,
|
||||
workspaceAgentActivity: hostA.workspaceAgentActivity,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps selector output equal when only wrapper objects change", () => {
|
||||
const workspaces = workspaceMap();
|
||||
const workspaceAgentActivity = activityMap();
|
||||
|
||||
const previous = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ workspaces, workspaceAgentActivity }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ workspaces, workspaceAgentActivity }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
expect(previous).not.toBe(next);
|
||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects workspace or activity index changes for selected hosts", () => {
|
||||
const workspaceAgentActivity = activityMap();
|
||||
const previous = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
100
packages/app/src/hooks/use-status-mode-workspaces.ts
Normal file
100
packages/app/src/hooks/use-status-mode-workspaces.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import {
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "./use-sidebar-workspaces-list";
|
||||
|
||||
const EMPTY_WORKSPACES: SidebarStatusWorkspacePlacement[] = [];
|
||||
const EMPTY_STATUS_SESSIONS: StatusModeSession[] = [];
|
||||
const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
|
||||
typeof useCreateFlowStore.getState
|
||||
>["pendingByDraftId"] = {};
|
||||
|
||||
interface StatusModeSessionSource {
|
||||
workspaces: SessionState["workspaces"];
|
||||
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
|
||||
}
|
||||
|
||||
export interface StatusModeSession {
|
||||
serverId: string;
|
||||
workspaces: SessionState["workspaces"];
|
||||
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
|
||||
}
|
||||
|
||||
export function selectStatusModeSessions(
|
||||
sessions: Record<string, StatusModeSessionSource | undefined>,
|
||||
serverIds: readonly string[],
|
||||
): StatusModeSession[] {
|
||||
const statusSessions: StatusModeSession[] = [];
|
||||
for (const serverId of serverIds) {
|
||||
const session = sessions[serverId];
|
||||
if (!session) {
|
||||
continue;
|
||||
}
|
||||
statusSessions.push({
|
||||
serverId,
|
||||
workspaces: session.workspaces,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
}
|
||||
return statusSessions;
|
||||
}
|
||||
|
||||
export function areStatusModeSessionsEqual(
|
||||
left: readonly StatusModeSession[],
|
||||
right: readonly StatusModeSession[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftSession = left[index];
|
||||
const rightSession = right[index];
|
||||
if (
|
||||
!leftSession ||
|
||||
!rightSession ||
|
||||
leftSession.serverId !== rightSession.serverId ||
|
||||
leftSession.workspaces !== rightSession.workspaces ||
|
||||
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useStatusModeWorkspacePlacements(input: {
|
||||
placements: SidebarWorkspacePlacement[];
|
||||
enabled?: boolean;
|
||||
}): SidebarStatusWorkspacePlacement[] {
|
||||
const isEnabled = input.enabled !== false && input.placements.length > 0;
|
||||
const serverIds = useMemo(
|
||||
() => Array.from(new Set(input.placements.map((placement) => placement.serverId))),
|
||||
[input.placements],
|
||||
);
|
||||
const statusSessions = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
isEnabled ? selectStatusModeSessions(state.sessions, serverIds) : EMPTY_STATUS_SESSIONS,
|
||||
areStatusModeSessionsEqual,
|
||||
);
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
||||
isEnabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isEnabled) {
|
||||
return EMPTY_WORKSPACES;
|
||||
}
|
||||
|
||||
return buildSidebarStatusWorkspacePlacements({
|
||||
placements: input.placements,
|
||||
sessions: statusSessions,
|
||||
pendingCreateAttempts,
|
||||
});
|
||||
}, [input.placements, isEnabled, pendingCreateAttempts, statusSessions]);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
@@ -15,17 +14,6 @@ function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
|
||||
}
|
||||
|
||||
describe("pruneMountedWorkspaceSelections", () => {
|
||||
it("retains the deck while an app-wide route temporarily clears the active workspace", () => {
|
||||
const mountedSelections = [workspace("A"), workspace("B")];
|
||||
|
||||
expect(
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
activeSelection: null,
|
||||
}),
|
||||
).toBe(mountedSelections);
|
||||
});
|
||||
|
||||
it("keeps the active workspace and the two most recent inactive workspaces", () => {
|
||||
const mountedAfterA = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [],
|
||||
@@ -76,22 +64,6 @@ describe("pruneMountedWorkspaceSelections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderWorkspaceSelectionsForStableRender", () => {
|
||||
it("does not move retained native roots when the active LRU order changes", () => {
|
||||
const activeA = [workspace("A"), workspace("B")];
|
||||
const activeB = [workspace("B"), workspace("A")];
|
||||
|
||||
expect(mountedWorkspaceIds(orderWorkspaceSelectionsForStableRender(activeA))).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
]);
|
||||
expect(mountedWorkspaceIds(orderWorkspaceSelectionsForStableRender(activeB))).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldKeepWorkspaceDeckEntryMounted", () => {
|
||||
it("keeps the active workspace mounted even when it is missing from hydrated workspaces", () => {
|
||||
expect(
|
||||
|
||||
@@ -43,7 +43,7 @@ export function pruneMountedWorkspaceSelections({
|
||||
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
|
||||
if (!activeSelection) {
|
||||
return currentSelections;
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxSelections = Math.max(1, maxMountedWorkspaces);
|
||||
@@ -74,14 +74,6 @@ export function pruneMountedWorkspaceSelections({
|
||||
return nextSelections;
|
||||
}
|
||||
|
||||
export function orderWorkspaceSelectionsForStableRender(
|
||||
selections: ActiveWorkspaceSelection[],
|
||||
): ActiveWorkspaceSelection[] {
|
||||
return [...selections].sort((left, right) =>
|
||||
getWorkspaceSelectionKey(left).localeCompare(getWorkspaceSelectionKey(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldKeepWorkspaceDeckEntryMounted({
|
||||
isActive,
|
||||
hasHydratedWorkspaces,
|
||||
|
||||
@@ -59,8 +59,7 @@ import {
|
||||
FloatingPanelPortalHostNameProvider,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { MountedTabActiveContext, SplitContainer } from "@/components/split-container";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
@@ -848,15 +847,21 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
const slotStyle = isVisible
|
||||
? styles.mobileMountedTabSlotVisible
|
||||
: styles.mobileMountedTabSlotHidden;
|
||||
|
||||
return (
|
||||
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||
<RetainedPanel active={isVisible} style={styles.mobileMountedTabSlot}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
<MountedTabActiveContext value={isVisible}>
|
||||
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
</MountedTabActiveContext>
|
||||
</RenderProfile>
|
||||
);
|
||||
});
|
||||
@@ -3960,8 +3965,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
position: "relative",
|
||||
},
|
||||
mobileMountedTabSlot: {
|
||||
mobileMountedTabSlotVisible: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
opacity: 1,
|
||||
},
|
||||
mobileMountedTabSlotHidden: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
opacity: 0,
|
||||
},
|
||||
contentPlaceholder: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1203,10 +1203,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
[serverId]: {
|
||||
...session,
|
||||
agents: nextAgents,
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
|
||||
nextAgents,
|
||||
session.workspaceAgentActivity,
|
||||
),
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(nextAgents),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -376,142 +376,6 @@ describe("stream reducer canonical tool calls", () => {
|
||||
assert.strictEqual(first.messageId, "msg-same");
|
||||
});
|
||||
|
||||
it("keeps row identities unique when an assistant message resumes after a tool", () => {
|
||||
const messageId = "msg-resumed";
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: assistantTimeline("Before the tool.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:00Z"),
|
||||
},
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId: "tool-between-assistant-segments",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:02:01Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("After the tool.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:02Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const messages = state.filter(
|
||||
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
|
||||
item.kind === "assistant_message",
|
||||
);
|
||||
expect(messages.map((message) => message.text)).toEqual([
|
||||
"Before the tool.",
|
||||
"After the tool.",
|
||||
]);
|
||||
expect(messages.map((message) => message.messageId)).toEqual([messageId, messageId]);
|
||||
expect(new Set(messages.map((message) => message.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps resumed live assistant rows when the turn completes", () => {
|
||||
const messageId = "msg-live-resumed";
|
||||
let tail: StreamItem[] = [];
|
||||
let head: StreamItem[] = [];
|
||||
|
||||
for (const update of [
|
||||
{
|
||||
event: assistantTimeline("Before the tool.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:00Z"),
|
||||
},
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId: "live-tool-between-assistant-segments",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:02:01Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("After the tool.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:02Z"),
|
||||
},
|
||||
{
|
||||
event: { type: "turn_completed" as const, provider: "codex" as const },
|
||||
timestamp: new Date("2025-01-01T10:02:03Z"),
|
||||
},
|
||||
]) {
|
||||
const result = applyStreamEvent({
|
||||
tail,
|
||||
head,
|
||||
event: update.event,
|
||||
timestamp: update.timestamp,
|
||||
});
|
||||
tail = result.tail;
|
||||
head = result.head;
|
||||
}
|
||||
|
||||
const messages = tail.filter(
|
||||
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
|
||||
item.kind === "assistant_message",
|
||||
);
|
||||
expect(head).toEqual([]);
|
||||
expect(messages.map((message) => message.text)).toEqual([
|
||||
"Before the tool.",
|
||||
"After the tool.",
|
||||
]);
|
||||
expect(messages.map((message) => message.messageId)).toEqual([messageId, messageId]);
|
||||
expect(new Set(messages.map((message) => message.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps every promoted block when an assistant message resumes after a tool", () => {
|
||||
const messageId = "msg-promoted-resume";
|
||||
let tail: StreamItem[] = [];
|
||||
let head: StreamItem[] = [];
|
||||
|
||||
for (const update of [
|
||||
{
|
||||
event: assistantTimeline("Before one.\n\nBefore two.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:00Z"),
|
||||
},
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "codex" as const,
|
||||
callId: "tool-between-promoted-segments",
|
||||
name: "shell",
|
||||
status: "completed" as const,
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:02:01Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("After one.\n\nAfter two.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:02Z"),
|
||||
},
|
||||
{
|
||||
event: { type: "turn_completed" as const, provider: "codex" as const },
|
||||
timestamp: new Date("2025-01-01T10:02:03Z"),
|
||||
},
|
||||
]) {
|
||||
const result = applyStreamEvent({
|
||||
tail,
|
||||
head,
|
||||
event: update.event,
|
||||
timestamp: update.timestamp,
|
||||
});
|
||||
tail = result.tail;
|
||||
head = result.head;
|
||||
}
|
||||
|
||||
const messages = tail.filter(
|
||||
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
|
||||
item.kind === "assistant_message",
|
||||
);
|
||||
expect(messages.map((message) => message.text)).toEqual([
|
||||
"Before one.",
|
||||
"Before two.",
|
||||
"After one.",
|
||||
"After two.",
|
||||
]);
|
||||
expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length);
|
||||
});
|
||||
|
||||
it("preserves old assistant merge behavior when message ids are absent", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
|
||||
@@ -43,35 +43,6 @@ function createUniqueTimelineId(
|
||||
return `${base}_${suffixSeed.toString(36)}`;
|
||||
}
|
||||
|
||||
function createAssistantItemId(
|
||||
state: StreamItem[],
|
||||
messageId: string | undefined,
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
): string {
|
||||
if (!messageId) {
|
||||
return createUniqueTimelineId(state, "assistant", text, timestamp);
|
||||
}
|
||||
|
||||
const isOccupied = (id: string) =>
|
||||
reservedItemIds?.has(id) === true || state.some((item) => item.id === id);
|
||||
if (!isOccupied(messageId)) {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
const segmentId = `${messageId}:segment:${timestamp.getTime().toString(36)}`;
|
||||
if (!isOccupied(segmentId)) {
|
||||
return segmentId;
|
||||
}
|
||||
|
||||
let suffix = 1;
|
||||
while (isOccupied(`${segmentId}:${suffix.toString(36)}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
return `${segmentId}:${suffix.toString(36)}`;
|
||||
}
|
||||
|
||||
export type StreamItem =
|
||||
| UserMessageItem
|
||||
| AssistantMessageItem
|
||||
@@ -199,11 +170,6 @@ export interface TodoListItem {
|
||||
|
||||
export type StreamUpdateSource = "live" | "canonical";
|
||||
|
||||
interface StreamUpdateOptions {
|
||||
source?: StreamUpdateSource;
|
||||
reservedItemIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -354,7 +320,6 @@ function appendAssistantMessage(
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
messageId?: string,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
@@ -397,7 +362,7 @@ function appendAssistantMessage(
|
||||
}
|
||||
|
||||
const idSeed = chunk.trim() || chunk;
|
||||
const entryId = createAssistantItemId(state, messageId, idSeed, timestamp, reservedItemIds);
|
||||
const entryId = messageId ?? createUniqueTimelineId(state, "assistant", idSeed, timestamp);
|
||||
const item: AssistantMessageItem = {
|
||||
kind: "assistant_message",
|
||||
id: entryId,
|
||||
@@ -786,7 +751,6 @@ function reduceTimelineEvent(
|
||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
): StreamItem[] {
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
@@ -794,14 +758,7 @@ function reduceTimelineEvent(
|
||||
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
|
||||
case "assistant_message":
|
||||
return finalizeActiveThoughts(
|
||||
appendAssistantMessage(
|
||||
state,
|
||||
item.text,
|
||||
timestamp,
|
||||
source,
|
||||
item.messageId,
|
||||
reservedItemIds,
|
||||
),
|
||||
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
|
||||
);
|
||||
case "reasoning":
|
||||
return appendThought(state, item.text, timestamp);
|
||||
@@ -841,12 +798,12 @@ export function reduceStreamUpdate(
|
||||
state: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
timestamp: Date,
|
||||
options?: StreamUpdateOptions,
|
||||
options?: { source?: StreamUpdateSource },
|
||||
): StreamItem[] {
|
||||
const source = options?.source ?? "live";
|
||||
switch (event.type) {
|
||||
case "timeline":
|
||||
return reduceTimelineEvent(state, event, timestamp, source, options?.reservedItemIds);
|
||||
return reduceTimelineEvent(state, event, timestamp, source);
|
||||
case "thread_started":
|
||||
case "turn_started":
|
||||
case "turn_completed":
|
||||
@@ -1216,17 +1173,7 @@ export function applyStreamEvent(params: {
|
||||
|
||||
// For streamable kinds, apply to head
|
||||
if (incomingKind !== null && isStreamableKind(incomingKind)) {
|
||||
const reservedItemIds =
|
||||
incomingKind === "assistant_message" && getActiveAssistantHeadIndex(nextHead) < 0
|
||||
? new Set(
|
||||
nextTail.flatMap((item) =>
|
||||
item.kind === "assistant_message" && item.blockGroupId
|
||||
? [item.id, item.blockGroupId]
|
||||
: [item.id],
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, reservedItemIds });
|
||||
const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source });
|
||||
if (reduced !== nextHead) {
|
||||
nextHead = reduced;
|
||||
changedHead = true;
|
||||
|
||||
@@ -92,22 +92,8 @@ describe("workspace agent activity index", () => {
|
||||
|
||||
expect(index).toEqual(
|
||||
new Map([
|
||||
[
|
||||
"workspace-a",
|
||||
{
|
||||
agentId: "permission",
|
||||
status: "needs_input",
|
||||
enteredAt: new Date("2026-06-01T10:01:00.000Z"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"workspace-b",
|
||||
{
|
||||
agentId: "attention",
|
||||
status: "attention",
|
||||
enteredAt: new Date("2026-06-01T10:02:00.000Z"),
|
||||
},
|
||||
],
|
||||
["workspace-a", { status: "needs_input", enteredAt: new Date("2026-06-01T10:01:00.000Z") }],
|
||||
["workspace-b", { status: "attention", enteredAt: new Date("2026-06-01T10:02:00.000Z") }],
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -149,82 +135,8 @@ describe("workspace agent activity index", () => {
|
||||
);
|
||||
|
||||
expect(index.get("workspace-a")).toEqual({
|
||||
agentId: "root",
|
||||
status: "running",
|
||||
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the activity index while the same agent remains in the same status", () => {
|
||||
const previous = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const next = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:05:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
previous,
|
||||
);
|
||||
|
||||
expect(next).toBe(previous);
|
||||
expect(next.get("workspace-a")?.enteredAt).toEqual(new Date("2026-06-01T10:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("records a new entry time when an agent changes status", () => {
|
||||
const previous = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const next = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "idle",
|
||||
updatedAt: "2026-06-01T10:05:00.000Z",
|
||||
pendingPermissionCount: 1,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
previous,
|
||||
);
|
||||
|
||||
expect(next).not.toBe(previous);
|
||||
expect(next.get("workspace-a")).toEqual({
|
||||
agentId: "root",
|
||||
status: "needs_input",
|
||||
enteredAt: new Date("2026-06-01T10:05:00.000Z"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,17 +2,14 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
|
||||
|
||||
export interface WorkspaceAgentActivity {
|
||||
agentId: string;
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceAgentActivityIndex(
|
||||
agents: ReadonlyMap<string, Agent>,
|
||||
previous?: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
): Map<string, WorkspaceAgentActivity> {
|
||||
const activityByWorkspaceId = new Map<string, WorkspaceAgentActivity>();
|
||||
const latestActivityAtByWorkspaceId = new Map<string, Date>();
|
||||
|
||||
for (const agent of agents.values()) {
|
||||
if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) {
|
||||
@@ -20,52 +17,21 @@ export function buildWorkspaceAgentActivityIndex(
|
||||
}
|
||||
|
||||
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
|
||||
const latestActivityAt = latestActivityAtByWorkspaceId.get(agent.workspaceId);
|
||||
if (latestActivityAt && enteredAt <= latestActivityAt) {
|
||||
const current = activityByWorkspaceId.get(agent.workspaceId);
|
||||
if (current && enteredAt <= (current.enteredAt ?? new Date(0))) {
|
||||
continue;
|
||||
}
|
||||
latestActivityAtByWorkspaceId.set(agent.workspaceId, enteredAt);
|
||||
|
||||
const status = deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
});
|
||||
activityByWorkspaceId.set(agent.workspaceId, {
|
||||
agentId: agent.id,
|
||||
status,
|
||||
status: deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
}),
|
||||
enteredAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [workspaceId, activity] of activityByWorkspaceId) {
|
||||
const previousActivity = previous?.get(workspaceId);
|
||||
if (
|
||||
previousActivity?.agentId === activity.agentId &&
|
||||
previousActivity.status === activity.status
|
||||
) {
|
||||
activityByWorkspaceId.set(workspaceId, previousActivity);
|
||||
}
|
||||
}
|
||||
|
||||
if (previous && areWorkspaceAgentActivityIndexesIdentical(previous, activityByWorkspaceId)) {
|
||||
return previous instanceof Map ? previous : new Map(previous);
|
||||
}
|
||||
return activityByWorkspaceId;
|
||||
}
|
||||
|
||||
function areWorkspaceAgentActivityIndexesIdentical(
|
||||
previous: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
next: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
): boolean {
|
||||
if (previous.size !== next.size) {
|
||||
return false;
|
||||
}
|
||||
for (const [workspaceId, activity] of next) {
|
||||
if (previous.get(workspaceId) !== activity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createCutoverProxy } from "./cutover-proxy.js";
|
||||
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
|
||||
|
||||
type RelayProtocolVersion = "1" | "2";
|
||||
@@ -98,7 +97,6 @@ function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined {
|
||||
|
||||
interface Env {
|
||||
RELAY: DurableObjectNamespace;
|
||||
PASEO_RELAY_UPSTREAM?: string;
|
||||
}
|
||||
|
||||
interface DurableObjectNamespace {
|
||||
@@ -574,10 +572,6 @@ export class RelayDurableObject {
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (env.PASEO_RELAY_UPSTREAM) {
|
||||
return createCutoverProxy(env.PASEO_RELAY_UPSTREAM).fetch(request);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Health check
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { createServer, type IncomingMessage, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import relayWorker from "./cloudflare-adapter.js";
|
||||
|
||||
type RelayEnv = Parameters<typeof relayWorker.fetch>[1];
|
||||
|
||||
interface ReceivedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
probe: string;
|
||||
}
|
||||
|
||||
class RecordingOrigin {
|
||||
private readonly requests: ReceivedRequest[] = [];
|
||||
private server: Server | null = null;
|
||||
|
||||
async start(): Promise<string> {
|
||||
this.server = createServer((request, response) => {
|
||||
this.requests.push(recordRequest(request));
|
||||
response.writeHead(202, { "content-type": "application/json", "x-relay-origin": "fly" });
|
||||
response.end(JSON.stringify({ status: "forwarded" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
|
||||
const address = this.server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
received(): ReceivedRequest[] {
|
||||
return this.requests;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const server = this.server;
|
||||
this.server = null;
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function recordRequest(request: IncomingMessage): ReceivedRequest {
|
||||
return {
|
||||
method: request.method ?? "",
|
||||
path: request.url ?? "",
|
||||
probe: String(request.headers["x-relay-probe"] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
describe("cutover proxy", () => {
|
||||
const origins: RecordingOrigin[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(origins.splice(0).map((origin) => origin.close()));
|
||||
});
|
||||
|
||||
it("forwards the request to the configured origin without changing its route", async () => {
|
||||
const origin = new RecordingOrigin();
|
||||
origins.push(origin);
|
||||
const originUrl = await origin.start();
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.paseo.sh/ws?serverId=srv_prod&role=server&v=2", {
|
||||
headers: { "x-relay-probe": "production" },
|
||||
}),
|
||||
{ PASEO_RELAY_UPSTREAM: originUrl } as RelayEnv,
|
||||
);
|
||||
|
||||
expect(origin.received()).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/ws?serverId=srv_prod&role=server&v=2",
|
||||
probe: "production",
|
||||
},
|
||||
]);
|
||||
expect(response.status).toBe(202);
|
||||
await expect(response.json()).resolves.toEqual({ status: "forwarded" });
|
||||
expect(response.headers.get("x-relay-origin")).toBe("fly");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
interface CutoverProxy {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
export function createCutoverProxy(origin: string): CutoverProxy {
|
||||
const originUrl = new URL(origin);
|
||||
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const upstreamUrl = new URL(request.url);
|
||||
upstreamUrl.protocol = originUrl.protocol;
|
||||
upstreamUrl.host = originUrl.host;
|
||||
|
||||
return fetch(new Request(upstreamUrl, request));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
// This live test uses the hosted relay's real TLS endpoint. Self-hosted relay TLS
|
||||
// opt-in is covered at URL-building/integration level so the local E2E does not
|
||||
// need to provision trusted certificates.
|
||||
const RELAY_BASE_URL = process.env.PASEO_LIVE_RELAY_URL ?? "wss://relay.paseo.sh";
|
||||
const RELAY_BASE_URL = "wss://relay.paseo.sh";
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
@@ -119,7 +119,6 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
// === Connect ===
|
||||
const daemonControlWs = new WebSocket(serverControlUrl);
|
||||
const clientWs = new WebSocket(clientUrl);
|
||||
const connected = waitForConnected(daemonControlWs, connectionId);
|
||||
let daemonWs: WebSocket | null = null;
|
||||
|
||||
try {
|
||||
@@ -128,7 +127,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
waitOpen(clientWs, "client"),
|
||||
]);
|
||||
|
||||
await connected;
|
||||
await waitForConnected(daemonControlWs, connectionId);
|
||||
|
||||
daemonWs = new WebSocket(serverDataUrl);
|
||||
await waitOpen(daemonWs, "server-data");
|
||||
|
||||
@@ -8,9 +8,6 @@ routes = [{ pattern = "relay.paseo.sh", custom_domain = true }]
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[vars]
|
||||
PASEO_RELAY_UPSTREAM = "https://paseo-relay-next.fly.dev"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "RELAY"
|
||||
class_name = "RelayDurableObject"
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"test:e2e:all": "vitest run e2e.test.ts --maxWorkers=1",
|
||||
"test:e2e:real": "npm run test:integration:real",
|
||||
"test:e2e:local": "npm run test:integration:local",
|
||||
"test:e2e:relay:live": "cross-env RUN_LIVE_RELAY_E2E=1 vitest run src/server/daemon-e2e/live-relay.real.e2e.test.ts --maxWorkers=1",
|
||||
"test:e2e:ui": "vitest --ui e2e.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -554,80 +554,6 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("surfaces an MCP elicitation and returns Codex's required approval action", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
await session.connect();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const permissionRequested = waitForNextPermission(session);
|
||||
appServer.requestMcpElicitation({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
serverName: "browser",
|
||||
message: "Allow the browser to open this page?",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
|
||||
const permission = await permissionRequested;
|
||||
expect(permission.request).toEqual({
|
||||
id: expect.any(String),
|
||||
provider: "codex",
|
||||
name: "CodexMcpElicitation",
|
||||
kind: "tool",
|
||||
title: "MCP approval: browser",
|
||||
description: "Allow the browser to open this page?",
|
||||
input: {
|
||||
mode: "openai/form",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
url: null,
|
||||
},
|
||||
metadata: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
serverName: "browser",
|
||||
elicitationId: null,
|
||||
},
|
||||
});
|
||||
await session.respondToPermission(permission.request.id, { behavior: "allow" });
|
||||
|
||||
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
|
||||
action: "accept",
|
||||
content: {},
|
||||
_meta: null,
|
||||
});
|
||||
appServer.resolvesMcpElicitation();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(events).toContainEqual({
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
requestId: permission.request.id,
|
||||
resolution: { behavior: "allow" },
|
||||
});
|
||||
});
|
||||
expect(events).not.toContainEqual({
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
requestId: permission.request.id,
|
||||
resolution: { behavior: "deny", interrupt: true },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("initializes Codex app-server without making Paseo the request originator", async () => {
|
||||
let initializeParams: unknown;
|
||||
const appServer = createFakeCodexAppServer({
|
||||
@@ -653,7 +579,7 @@ describe("Codex app-server provider", () => {
|
||||
title: "Codex App Server Daemon",
|
||||
version: "0.0.0",
|
||||
},
|
||||
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
|
||||
capabilities: { experimentalApi: true },
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
await session.close();
|
||||
@@ -3845,7 +3771,7 @@ describe("Codex importable sessions", () => {
|
||||
title: "Codex App Server Daemon",
|
||||
version: "0.0.0",
|
||||
},
|
||||
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
|
||||
capabilities: { experimentalApi: true },
|
||||
},
|
||||
},
|
||||
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
|
||||
|
||||
@@ -2997,13 +2997,12 @@ export function buildCodexAppServerEnv(
|
||||
|
||||
function buildCodexAppServerInitializeParams(): {
|
||||
clientInfo: { name: string; title: string; version: string };
|
||||
capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true };
|
||||
capabilities: { experimentalApi: true };
|
||||
} {
|
||||
return {
|
||||
clientInfo: CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO,
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
mcpServerOpenaiFormElicitation: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3082,12 +3081,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private historyPending = false;
|
||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private mcpElicitationPermissionIds = new Map<number, string>();
|
||||
private pendingPermissionHandlers = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
kind: "command" | "file" | "question" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
}
|
||||
@@ -3429,9 +3427,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.client.setRequestHandler("item/tool/requestUserInput", (params) =>
|
||||
this.handleToolApprovalRequest(params),
|
||||
);
|
||||
this.client.setRequestHandler("mcpServer/elicitation/request", (params, requestId) =>
|
||||
this.handleMcpElicitationRequest(params, requestId),
|
||||
);
|
||||
// Keep the legacy method name for older Codex builds.
|
||||
this.client.setRequestHandler("tool/requestUserInput", (params) =>
|
||||
this.handleToolApprovalRequest(params),
|
||||
@@ -3954,15 +3949,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.kind === "mcp_elicitation") {
|
||||
pending.resolve({
|
||||
action: resolvePermissionDecision(response),
|
||||
content: response.behavior === "allow" ? {} : null,
|
||||
_meta: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = pending.questions ?? [];
|
||||
const itemId =
|
||||
typeof pendingRequest?.metadata?.itemId === "string"
|
||||
@@ -4017,7 +4003,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
response: AgentPermissionResponse;
|
||||
pending: {
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
kind: "command" | "file" | "question" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
};
|
||||
@@ -4484,27 +4470,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: unknown): void {
|
||||
const notificationParams = toObjectRecord(params);
|
||||
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
|
||||
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
|
||||
if (requestId) {
|
||||
const pending = this.pendingPermissionHandlers.get(requestId);
|
||||
this.mcpElicitationPermissionIds.delete(notificationParams.requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
pending.resolve({ action: "cancel", content: null, _meta: null });
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId,
|
||||
resolution: { behavior: "deny", interrupt: true },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const parsed = CodexNotificationSchema.parse({ method, params });
|
||||
this.traceParsedNotification(method, params, parsed);
|
||||
const route = this.resolveCodexThreadRoute(getCodexNotificationThreadId(parsed));
|
||||
@@ -5855,54 +5820,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleMcpElicitationRequest(params: unknown, serverRequestId: number): Promise<unknown> {
|
||||
const parsed = z
|
||||
.object({
|
||||
threadId: z.string(),
|
||||
turnId: z.string().nullable().optional(),
|
||||
serverName: z.string(),
|
||||
mode: z.enum(["form", "openai/form", "url"]),
|
||||
message: z.string(),
|
||||
requestedSchema: z.unknown().optional(),
|
||||
url: z.string().optional(),
|
||||
elicitationId: z.string().optional(),
|
||||
})
|
||||
.parse(params);
|
||||
if (parsed.mode === "url") {
|
||||
return Promise.resolve({ action: "decline", content: null, _meta: null });
|
||||
}
|
||||
const requiredFields = toObjectRecord(parsed.requestedSchema)?.required;
|
||||
if (Array.isArray(requiredFields) && requiredFields.length > 0) {
|
||||
return Promise.resolve({ action: "decline", content: null, _meta: null });
|
||||
}
|
||||
const requestId = `permission-${randomUUID()}`;
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
provider: CODEX_PROVIDER,
|
||||
name: "CodexMcpElicitation",
|
||||
kind: "tool",
|
||||
title: `MCP approval: ${parsed.serverName}`,
|
||||
description: parsed.message,
|
||||
input: {
|
||||
mode: parsed.mode,
|
||||
requestedSchema: parsed.requestedSchema ?? null,
|
||||
url: parsed.url ?? null,
|
||||
},
|
||||
metadata: {
|
||||
threadId: parsed.threadId,
|
||||
turnId: parsed.turnId ?? null,
|
||||
serverName: parsed.serverName,
|
||||
elicitationId: parsed.elicitationId ?? null,
|
||||
},
|
||||
};
|
||||
this.pendingPermissions.set(requestId, request);
|
||||
this.mcpElicitationPermissionIds.set(serverRequestId, requestId);
|
||||
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||
return new Promise((resolve) => {
|
||||
this.pendingPermissionHandlers.set(requestId, { resolve, kind: "mcp_elicitation" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexAppServerAgentClient implements AgentClient {
|
||||
|
||||
@@ -33,7 +33,7 @@ interface PendingRequest {
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
type RequestHandler = (params: unknown, requestId: number) => unknown;
|
||||
type RequestHandler = (params: unknown) => unknown;
|
||||
type NotificationHandler = (method: string, params: unknown) => void;
|
||||
|
||||
export interface CodexThreadForkParams {
|
||||
@@ -321,7 +321,7 @@ export class CodexAppServerClient {
|
||||
this.traceRawEvent(request);
|
||||
const handler = this.requestHandlers.get(request.method);
|
||||
try {
|
||||
const result = handler ? await handler(request.params, request.id) : {};
|
||||
const result = handler ? await handler(request.params) : {};
|
||||
this.writeJsonRpcResponse({ id: request.id, result });
|
||||
} catch (error) {
|
||||
this.writeJsonRpcResponse({
|
||||
|
||||
@@ -61,15 +61,6 @@ export interface FakeCodexAppServer {
|
||||
reason: string;
|
||||
}): void;
|
||||
waitForCommandApprovalDecision(itemId: string): Promise<unknown>;
|
||||
requestMcpElicitation(params: {
|
||||
threadId: string;
|
||||
turnId: string | null;
|
||||
serverName: string;
|
||||
message: string;
|
||||
requestedSchema: Record<string, unknown>;
|
||||
}): void;
|
||||
waitForMcpElicitationDecision(): Promise<unknown>;
|
||||
resolvesMcpElicitation(): void;
|
||||
}
|
||||
|
||||
export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
|
||||
@@ -148,7 +139,6 @@ export function createFakeCodexAppServer(
|
||||
const messages: JsonObject[] = [];
|
||||
const errors: Error[] = [];
|
||||
const approvalRequestIds = new Map<string, number>();
|
||||
let mcpElicitationRequestId: number | undefined;
|
||||
const waiters = new Set<{
|
||||
predicate: (message: JsonObject) => boolean;
|
||||
resolve: (message: JsonObject) => void;
|
||||
@@ -412,42 +402,6 @@ export function createFakeCodexAppServer(
|
||||
);
|
||||
return message.result;
|
||||
},
|
||||
requestMcpElicitation(params) {
|
||||
const requestId = nextServerRequestId;
|
||||
nextServerRequestId += 1;
|
||||
mcpElicitationRequestId = requestId;
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: requestId,
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: {
|
||||
...params,
|
||||
mode: "openai/form",
|
||||
_meta: null,
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
},
|
||||
async waitForMcpElicitationDecision() {
|
||||
if (mcpElicitationRequestId === undefined) {
|
||||
throw new Error("No pending fake Codex app-server MCP elicitation");
|
||||
}
|
||||
const message = await waitForMessage(
|
||||
(candidate) =>
|
||||
candidate.id === mcpElicitationRequestId &&
|
||||
!("method" in candidate) &&
|
||||
"result" in candidate,
|
||||
"MCP elicitation response",
|
||||
);
|
||||
return message.result;
|
||||
},
|
||||
resolvesMcpElicitation() {
|
||||
if (mcpElicitationRequestId === undefined) {
|
||||
throw new Error("No pending fake Codex app-server MCP elicitation");
|
||||
}
|
||||
writeNotification("serverRequest/resolved", { requestId: mcpElicitationRequestId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||
import {
|
||||
parseConnectionOfferFromUrl,
|
||||
type ConnectionOffer,
|
||||
} from "@getpaseo/protocol/connection-offer";
|
||||
|
||||
const relayEndpoint = process.env.PASEO_LIVE_RELAY_ENDPOINT ?? "paseo-relay-next.fly.dev:443";
|
||||
const liveTest = process.env.RUN_LIVE_RELAY_E2E === "1" ? test : test.skip;
|
||||
|
||||
function requireOffer(url: string): ConnectionOffer {
|
||||
const offer = parseConnectionOfferFromUrl(url);
|
||||
if (!offer) {
|
||||
throw new Error("Pairing did not produce a relay connection offer");
|
||||
}
|
||||
return offer;
|
||||
}
|
||||
|
||||
async function pairingOfferFor(daemon: TestPaseoDaemon): Promise<ConnectionOffer> {
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: true,
|
||||
relayEndpoint,
|
||||
relayPublicEndpoint: relayEndpoint,
|
||||
relayUseTls: true,
|
||||
relayPublicUseTls: true,
|
||||
includeQr: false,
|
||||
});
|
||||
if (!pairing.url) {
|
||||
throw new Error("Pairing did not produce a URL");
|
||||
}
|
||||
return requireOffer(pairing.url);
|
||||
}
|
||||
|
||||
function clientFor(offer: ConnectionOffer): DaemonClient {
|
||||
return new DaemonClient({
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: offer.relay.endpoint,
|
||||
useTls: true,
|
||||
serverId: offer.serverId,
|
||||
role: "client",
|
||||
}),
|
||||
clientId: "clid_live_relay_acceptance",
|
||||
clientType: "cli",
|
||||
connectTimeoutMs: 30_000,
|
||||
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
describe("live hosted relay", () => {
|
||||
let daemon: TestPaseoDaemon | null = null;
|
||||
let client: DaemonClient | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
await daemon?.close();
|
||||
});
|
||||
|
||||
liveTest(
|
||||
"carries a complete DaemonClient agent workflow through the hosted relay",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
daemon = await createTestPaseoDaemon({
|
||||
listen: "127.0.0.1",
|
||||
relayEnabled: true,
|
||||
relayEndpoint,
|
||||
relayUseTls: true,
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const offer = await pairingOfferFor(daemon);
|
||||
client = clientFor(offer);
|
||||
|
||||
await client.connect();
|
||||
const initialAgents = await client.fetchAgents();
|
||||
const agent = await client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
title: "Live relay acceptance",
|
||||
modeId: "full-access",
|
||||
});
|
||||
await client.sendMessage(agent.id, "Respond with exactly: RELAY_ACCEPTANCE_OK");
|
||||
const finished = await client.waitForFinish(agent.id, 120_000);
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 20,
|
||||
projection: "canonical",
|
||||
});
|
||||
const assistantText = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => entry.item.text)
|
||||
.join("");
|
||||
|
||||
expect(initialAgents.entries).toEqual([]);
|
||||
expect(agent).toMatchObject({
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
status: "idle",
|
||||
});
|
||||
expect(finished).toMatchObject({ status: "idle" });
|
||||
expect(assistantText).toMatch(/^RELAY_ACCEPTANCE_OK\s*$/);
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
});
|
||||
@@ -23,8 +23,6 @@ interface TestPaseoDaemonOptions {
|
||||
isDev?: boolean;
|
||||
relayEnabled?: boolean;
|
||||
relayEndpoint?: string;
|
||||
relayUseTls?: boolean;
|
||||
relayPublicUseTls?: boolean;
|
||||
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
providerOverrides?: PaseoDaemonConfig["providerOverrides"];
|
||||
paseoHomeRoot?: string;
|
||||
@@ -168,8 +166,6 @@ async function prepareTestDaemonConfig(
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: options.relayEnabled ?? false,
|
||||
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
|
||||
relayUseTls: options.relayUseTls,
|
||||
relayPublicUseTls: options.relayPublicUseTls,
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
auth: options.auth,
|
||||
pushNotificationSender: options.pushNotificationSender,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
diff --git a/node_modules/react-native-markdown-display/src/lib/AstRenderer.js b/node_modules/react-native-markdown-display/src/lib/AstRenderer.js
|
||||
index 608e945..edafe12 100644
|
||||
--- a/node_modules/react-native-markdown-display/src/lib/AstRenderer.js
|
||||
+++ b/node_modules/react-native-markdown-display/src/lib/AstRenderer.js
|
||||
@@ -1,6 +1,5 @@
|
||||
import {StyleSheet} from 'react-native';
|
||||
|
||||
-import getUniqueID from './util/getUniqueID';
|
||||
import convertAdditionalStyles from './util/convertAdditionalStyles';
|
||||
|
||||
import textStyleProps from './data/textStyleProps';
|
||||
@@ -180,7 +179,7 @@ export default class AstRenderer {
|
||||
* @return {*}
|
||||
*/
|
||||
render = (nodes) => {
|
||||
- const root = {type: 'body', key: getUniqueID(), children: nodes};
|
||||
+ const root = {type: 'body', key: 'rnmr_root', children: nodes};
|
||||
return this.renderNode(root, [], true);
|
||||
};
|
||||
}
|
||||
diff --git a/node_modules/react-native-markdown-display/src/lib/util/tokensToAST.js b/node_modules/react-native-markdown-display/src/lib/util/tokensToAST.js
|
||||
index b0ed265..68e2150 100644
|
||||
--- a/node_modules/react-native-markdown-display/src/lib/util/tokensToAST.js
|
||||
+++ b/node_modules/react-native-markdown-display/src/lib/util/tokensToAST.js
|
||||
@@ -1,4 +1,3 @@
|
||||
-import getUniqueID from './getUniqueID';
|
||||
import getTokenTypeByToken from './getTokenTypeByToken';
|
||||
|
||||
/**
|
||||
@@ -7,9 +6,10 @@ import getTokenTypeByToken from './getTokenTypeByToken';
|
||||
* @param {number} tokenIndex
|
||||
* @return {{type: string, content, tokenIndex: *, index: number, attributes: {}, children: *}}
|
||||
*/
|
||||
-function createNode(token, tokenIndex) {
|
||||
+function createNode(token, tokenIndex, parentKey) {
|
||||
const type = getTokenTypeByToken(token);
|
||||
const content = token.content;
|
||||
+ const keyPath = parentKey ? `${parentKey}.${tokenIndex}` : `${tokenIndex}`;
|
||||
|
||||
let attributes = {};
|
||||
|
||||
@@ -27,12 +27,12 @@ function createNode(token, tokenIndex) {
|
||||
sourceMeta: token.meta,
|
||||
block: token.block,
|
||||
markup: token.markup,
|
||||
- key: getUniqueID() + '_' + type,
|
||||
+ key: `rnmr_${keyPath}_${type}`,
|
||||
content,
|
||||
tokenIndex,
|
||||
index: 0,
|
||||
attributes,
|
||||
- children: tokensToAST(token.children),
|
||||
+ children: tokensToAST(token.children, keyPath),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function createNode(token, tokenIndex) {
|
||||
* @param {Array<{type: string, tag:string, content: string, children: *, attrs: Array}>}tokens
|
||||
* @return {Array}
|
||||
*/
|
||||
-export default function tokensToAST(tokens) {
|
||||
+export default function tokensToAST(tokens, parentKey = '') {
|
||||
let stack = [];
|
||||
let children = [];
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function tokensToAST(tokens) {
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
- const astNode = createNode(token, i);
|
||||
+ const astNode = createNode(token, i, parentKey);
|
||||
|
||||
if (
|
||||
!(
|
||||
@@ -5,10 +5,6 @@ import { join } from "node:path";
|
||||
// In CI we often install a single workspace (e.g. server/relay/website). Only apply patches
|
||||
// when the patched dependency is actually present.
|
||||
const patchedPackages = [
|
||||
{
|
||||
nodeModulesPath: "node_modules/react-native-markdown-display",
|
||||
patchPrefix: "react-native-markdown-display+",
|
||||
},
|
||||
{
|
||||
nodeModulesPath: "node_modules/react-native-draggable-flatlist",
|
||||
patchPrefix: "react-native-draggable-flatlist+",
|
||||
|
||||
Reference in New Issue
Block a user