Improve workspace navigation and render isolation

This commit is contained in:
Mohamed Boudra
2026-04-20 16:14:22 +07:00
parent 403fb03c97
commit a6423f7f0a
46 changed files with 2801 additions and 862 deletions
@@ -0,0 +1,438 @@
/**
* @vitest-environment jsdom
*/
import React, { useImperativeHandle } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DaemonClient } from "@server/client/daemon-client";
import { PaneFocusProvider, PaneProvider } from "@/panels/pane-context";
import { agentPanelRegistration } from "@/panels/agent-panel";
import { useDraftStore } from "@/stores/draft-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
type PanelTestTheme = {
colors: {
foreground: string;
foregroundMuted: string;
surface0: string;
surface1: string;
surface2: string;
surface3: string;
border: string;
destructive: string;
};
spacing: Record<number, number>;
borderRadius: Record<string, number>;
fontSize: Record<string, number>;
fontWeight: Record<string, string>;
iconSize: Record<string, number>;
};
type PanelTestStyles = Record<string, unknown>;
type PanelTestStyleFactory = (input: PanelTestTheme) => PanelTestStyles;
const {
composerRenderCount,
composerUnmountCount,
latestComposerCwd,
streamRenderCount,
latestStreamPermissionKeys,
latestStreamText,
theme,
runtimeClient,
} = vi.hoisted(() => {
Object.defineProperty(globalThis, "__DEV__", {
value: false,
configurable: true,
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
Object.defineProperty(globalThis, "ResizeObserver", {
value: class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
configurable: true,
});
return {
composerRenderCount: vi.fn(),
composerUnmountCount: vi.fn(),
latestComposerCwd: { current: null as string | null },
streamRenderCount: vi.fn(),
latestStreamPermissionKeys: { current: [] as string[] },
latestStreamText: { current: null as string | null },
theme: {
colors: {
foreground: "#ffffff",
foregroundMuted: "#999999",
surface0: "#000000",
surface1: "#111111",
surface2: "#222222",
surface3: "#333333",
border: "#444444",
destructive: "#ff0000",
},
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
borderRadius: { md: 6, lg: 8, xl: 12 },
fontSize: { sm: 13, base: 15, lg: 18 },
fontWeight: { medium: "500" },
iconSize: { lg: 22 },
} satisfies PanelTestTheme,
runtimeClient: {
fetchAgent: vi.fn(),
fetchAgentTimeline: vi.fn(),
},
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
}));
vi.mock("react-native-unistyles", () => {
return {
StyleSheet: {
create: createPanelTestStyles,
},
useUnistyles: () => ({ theme }),
};
});
function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles): PanelTestStyles {
return typeof factory === "function" ? factory(theme) : factory;
}
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "server", label: "Test server" }],
useHostRuntimeClient: () => runtimeClient,
useHostRuntimeIsConnected: () => false,
useHostRuntimeConnectionStatus: () => "offline",
useHostRuntimeLastError: () => null,
}));
vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: vi.fn(async () => {}),
persistAttachmentFromDataUrl: vi.fn(),
persistAttachmentFromFileUri: vi.fn(),
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => null,
}));
vi.mock("@/components/file-drop-zone", () => ({
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/toast-host", () => ({
ToastViewport: () => null,
useToastHost: () => ({
toast: null,
dismiss: vi.fn(),
api: {
show: vi.fn(),
},
}),
}));
vi.mock("@/components/archived-agent-callout", () => ({
ArchivedAgentCallout: ({ agentId }: { agentId: string }) => (
<div data-testid="archived-agent-callout">{agentId}</div>
),
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: null }),
}));
vi.mock("@/hooks/use-archive-agent", () => ({
useArchiveAgent: () => ({
isArchivingAgent: () => false,
}),
}));
vi.mock("@/components/composer", () => ({
Composer: ({ cwd }: { cwd: string }) => {
React.useEffect(
() => () => {
composerUnmountCount();
},
[],
);
composerRenderCount();
latestComposerCwd.current = cwd;
return <div data-testid="composer">{cwd}</div>;
},
}));
vi.mock("@/components/agent-stream-view", () => ({
AgentStreamView: React.memo(
React.forwardRef<
{ prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void },
{ pendingPermissions: Map<string, PendingPermission>; streamItems: StreamItem[] }
>(function AgentStreamView({ pendingPermissions, streamItems }, ref) {
useImperativeHandle(ref, () => ({
prepareForViewportChange: vi.fn(),
scrollToBottom: vi.fn(),
}));
streamRenderCount();
latestStreamPermissionKeys.current = Array.from(pendingPermissions.keys());
latestStreamText.current =
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
}),
),
}));
function makeClient(): DaemonClient {
return new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "panel-render-isolation-test",
});
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
const now = new Date("2026-04-20T00:00:00.000Z");
return {
serverId: "server",
id: "agent",
provider: "codex",
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: null,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
lastError: null,
title: "Render isolation",
cwd: "/workspace/one",
model: null,
labels: {},
...overrides,
};
}
function seedReadyAgent(agent: Agent = makeAgent()) {
const store = useSessionStore.getState();
store.initializeSession("server", makeClient());
store.setAgents("server", new Map([["agent", agent]]));
store.setAgentAuthoritativeHistoryApplied("server", "agent", true);
}
async function renderAgentPanel(root: Root) {
const AgentPanel = agentPanelRegistration.component;
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<PaneProvider
value={{
serverId: "server",
workspaceId: "workspace",
tabId: "agent-agent",
target: { kind: "agent", agentId: "agent" },
openTab: vi.fn(),
closeCurrentTab: vi.fn(),
retargetCurrentTab: vi.fn(),
openFileInWorkspace: vi.fn(),
}}
>
<PaneFocusProvider
value={{ isWorkspaceFocused: true, isPaneFocused: false, isInteractive: false }}
>
<AgentPanel />
</PaneFocusProvider>
</PaneProvider>
</QueryClientProvider>,
);
await Promise.resolve();
});
}
function updateCurrentAgentStream(text: string) {
act(() => {
useSessionStore.getState().setAgentStreamTail("server", (previous) => {
const next = new Map(previous);
next.set("agent", [
{
kind: "user_message",
id: `message-${text}`,
text,
timestamp: new Date("2026-04-20T00:00:01.000Z"),
},
]);
return next;
});
});
}
function makePendingPermission(agentId: string, requestId: string): PendingPermission {
const request: AgentPermissionRequest = {
id: requestId,
provider: "codex",
name: `permission-${requestId}`,
kind: "tool",
};
const key = `${agentId}:${requestId}`;
return {
key,
agentId,
request,
};
}
function updatePendingPermissions(permissions: PendingPermission[]) {
act(() => {
useSessionStore
.getState()
.setPendingPermissions(
"server",
new Map(permissions.map((permission) => [permission.key, permission])),
);
});
}
async function updateCurrentAgentCwd(cwd: string) {
await act(async () => {
useSessionStore.getState().setAgents("server", (previous) => {
const current = previous.get("agent");
if (!current) {
throw new Error("Expected seeded agent");
}
const next = new Map(previous);
next.set("agent", { ...current, cwd });
return next;
});
await Promise.resolve();
});
}
describe("AgentPanel render isolation", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
useDraftStore.setState({ drafts: {}, createModalDraft: null });
composerRenderCount.mockClear();
composerUnmountCount.mockClear();
latestComposerCwd.current = null;
streamRenderCount.mockClear();
latestStreamPermissionKeys.current = [];
latestStreamText.current = null;
});
it("refreshes the stream view without invoking Composer for stream-only updates", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
const composerBaseline = composerRenderCount.mock.calls.length;
const streamBaseline = streamRenderCount.mock.calls.length;
expect(composerBaseline).toBeGreaterThan(0);
expect(streamBaseline).toBeGreaterThan(0);
updateCurrentAgentStream("stream-only update");
expect(latestStreamText.current).toBe("stream-only update");
expect(streamRenderCount).toHaveBeenCalledTimes(streamBaseline + 1);
expect(composerRenderCount).toHaveBeenCalledTimes(composerBaseline);
});
it("still invokes Composer for current-agent cwd changes and unmounts it for archives", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
expect(latestComposerCwd.current).toBe("/workspace/one");
const composerBaseline = composerRenderCount.mock.calls.length;
expect(composerBaseline).toBeGreaterThan(0);
await updateCurrentAgentCwd("/workspace/two");
expect(latestComposerCwd.current).toBe("/workspace/two");
expect(composerRenderCount.mock.calls.length).toBeGreaterThan(composerBaseline);
await act(async () => {
useSessionStore.getState().setAgents("server", (previous) => {
const current = previous.get("agent");
if (!current) {
throw new Error("Expected seeded agent");
}
const next = new Map(previous);
next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") });
return next;
});
await Promise.resolve();
});
expect(composerUnmountCount).toHaveBeenCalledTimes(1);
});
it("keeps stream permissions stable for unrelated pending permission updates", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
const initialRenderCount = streamRenderCount.mock.calls.length;
expect(initialRenderCount).toBeGreaterThan(0);
expect(latestStreamPermissionKeys.current).toEqual([]);
const unrelatedPermission = makePendingPermission("other-agent", "unrelated");
updatePendingPermissions([unrelatedPermission]);
expect(latestStreamPermissionKeys.current).toEqual([]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount);
const agentPermission = makePendingPermission("agent", "current-agent");
updatePendingPermissions([unrelatedPermission, agentPermission]);
expect(latestStreamPermissionKeys.current).toEqual(["agent:current-agent"]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 1);
updatePendingPermissions([unrelatedPermission]);
expect(latestStreamPermissionKeys.current).toEqual([]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2);
});
});
+338 -165
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import invariant from "tiny-invariant";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
@@ -23,7 +23,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import {
useHostRuntimeClient,
@@ -111,7 +111,8 @@ function useAgentPanelDescriptor(
}
function AgentPanel() {
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
const { serverId, target, openFileInWorkspace } = usePaneContext();
const { isPaneFocused } = usePaneFocus();
invariant(target.kind === "agent", "AgentPanel requires agent target");
function openWorkspaceFile(input: { filePath: string }) {
@@ -137,6 +138,12 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = {
};
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
type RouteBottomAnchorRequest = ReturnType<typeof deriveRouteBottomAnchorRequest>;
type PendingCreateByDraftId = ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
type PendingCreateAttempt = PendingCreateByDraftId[string];
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {}
@@ -151,6 +158,27 @@ function isNotFoundErrorMessage(message: string): boolean {
return /agent not found|not found/i.test(message);
}
function findPendingCreateForPanel(input: {
pendingByDraftId: PendingCreateByDraftId;
serverId: string;
agentId?: string;
}): PendingCreateAttempt | null {
if (!input.agentId) {
return null;
}
const values = Object.values(input.pendingByDraftId);
for (const entry of values) {
if (
entry.lifecycle === "active" &&
entry.serverId === input.serverId &&
entry.agentId === input.agentId
) {
return entry;
}
}
return null;
}
type AgentLookupState =
| { tag: "idle" }
| { tag: "loading" }
@@ -455,26 +483,13 @@ function ChatAgentContent({
};
}),
);
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId: agentId ?? "__pending__",
}),
initialCwd: agentState.cwd ?? "",
});
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) =>
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
const isInitializingFromMap = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
);
@@ -489,11 +504,7 @@ function ChatAgentContent({
const agentHistorySyncGeneration = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
);
const allPendingPermissions = useSessionStore(
(state) => state.sessions[serverId]?.pendingPermissions,
);
const setAgents = useSessionStore((state) => state.setAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const { ensureAgentIsInitialized } = useAgentInitialization({
@@ -504,38 +515,13 @@ function ChatAgentContent({
kind: "idle",
});
const pendingCreate = useMemo(() => {
if (!agentId) {
return null;
}
const values = Object.values(pendingByDraftId);
for (const entry of values) {
if (
entry.lifecycle === "active" &&
entry.serverId === serverId &&
entry.agentId === agentId
) {
return entry;
}
}
return null;
}, [agentId, pendingByDraftId, serverId]);
const pendingCreate = useMemo(
() => findPendingCreateForPanel({ pendingByDraftId, serverId, agentId }),
[agentId, pendingByDraftId, serverId],
);
const isPendingCreateForPanel = Boolean(pendingCreate);
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
const pendingPermissions = useMemo(() => {
if (!allPendingPermissions || !agentId) {
return new Map<string, PendingPermission>();
}
const filtered = new Map<string, PendingPermission>();
for (const [key, permission] of allPendingPermissions) {
if (permission.agentId === agentId) {
filtered.set(key, permission);
}
}
return filtered;
}, [agentId, allPendingPermissions]);
const attentionController = useAgentAttentionClear({
agentId,
client,
@@ -640,38 +626,7 @@ function ChatAgentContent({
return agentHistorySyncGeneration < historySyncGeneration;
}, [agentHistorySyncGeneration, agentId, historySyncGeneration]);
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (!isPendingCreateForPanel || !pendingCreate) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: pendingCreate.clientMessageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
...(pendingCreate.images && pendingCreate.images.length > 0
? { images: pendingCreate.images }
: {}),
},
];
}, [isPendingCreateForPanel, pendingCreate]);
const mergedStreamItems = useMemo<StreamItem[]>(() => {
if (optimisticStreamItems.length === 0) {
return streamItems;
}
const optimistic = optimisticStreamItems[0];
if (!optimistic) {
return streamItems;
}
const alreadyHasOptimistic = streamItems.some(
(item) => item.kind === "user_message" && item.id === optimistic.id,
);
return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems];
}, [optimisticStreamItems, streamItems]);
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
const shouldUseOptimisticStream = isPendingCreateForPanel;
const authoritativeStatus = agentState.status;
const isAuthoritativeBootstrapping =
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
@@ -745,52 +700,29 @@ function ChatAgentContent({
[effectiveAgent?.id],
);
useEffect(() => {
if (!isPendingCreateForPanel || !pendingCreate) {
const handleComposerHeightChange = useCallback(
(height: number) => {
if (!agentId) {
return;
}
logWebStickyBottom("screen_composer_height_change", {
agentId,
height,
});
streamViewRef.current?.prepareForViewportChange();
},
[agentId],
);
const handleMessageSent = useCallback(() => {
if (!agentId) {
return;
}
const hasUserMessage = streamItems.some(
(item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId,
);
if (hasUserMessage && canFinalizePendingCreate) {
if (agentId && pendingCreate.images && pendingCreate.images.length > 0) {
setAgentStreamTail(serverId, (previous) => {
const current = previous.get(agentId);
if (!current) {
return previous;
}
const merged = mergePendingCreateImages({
streamItems: current,
clientMessageId: pendingCreate.clientMessageId,
images: pendingCreate.images,
});
if (merged === current) {
return previous;
}
const next = new Map(previous);
next.set(agentId, merged);
return next;
});
}
markPendingCreateLifecycle({
draftId: pendingCreate.draftId,
lifecycle: "sent",
});
clearPendingCreate({ draftId: pendingCreate.draftId });
}
}, [
agentId,
canFinalizePendingCreate,
clearPendingCreate,
isPendingCreateForPanel,
markPendingCreateLifecycle,
pendingCreate,
serverId,
setAgentStreamTail,
streamItems,
]);
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
agentId,
});
streamViewRef.current?.scrollToBottom("message-sent");
}, [agentId]);
useEffect(() => {
if (!agentId) {
@@ -947,53 +879,35 @@ function ChatAgentContent({
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
<AgentStreamView
ref={streamViewRef}
agentId={effectiveAgent.id}
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
<Composer
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
attachments={agentInputDraft.attachments}
onChangeAttachments={agentInputDraft.setAttachments}
cwd={agentInputDraft.cwd}
clearDraft={agentInputDraft.clear}
autoFocus={isPaneFocused}
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={(height) => {
logWebStickyBottom("screen_composer_height_change", {
agentId,
height,
});
streamViewRef.current?.prepareForViewportChange();
}}
onMessageSent={() => {
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
agentId,
});
streamViewRef.current?.scrollToBottom("message-sent");
}}
/>
) : agentId && agentState.archivedAt ? (
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
) : null}
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
initialCwd={agentState.cwd ?? ""}
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
@@ -1022,6 +936,265 @@ function ChatAgentContent({
);
}
function AgentStreamSection({
streamViewRef,
serverId,
agentId,
agent,
pendingCreate,
shouldUseOptimisticStream,
canFinalizePendingCreate,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
onOpenWorkspaceFile,
}: {
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
agentId?: string;
agent: AgentScreenAgent;
pendingCreate: PendingCreateAttempt | null;
shouldUseOptimisticStream: boolean;
canFinalizePendingCreate: boolean;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingPermissionList = useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!agentId) {
return EMPTY_PENDING_PERMISSION_LIST;
}
const allPendingPermissions = state.sessions[serverId]?.pendingPermissions;
if (!allPendingPermissions) {
return EMPTY_PENDING_PERMISSION_LIST;
}
const filtered: PendingPermission[] = [];
for (const permission of allPendingPermissions.values()) {
if (permission.agentId === agentId) {
filtered.push(permission);
}
}
return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST;
},
shallow,
);
const pendingPermissions = useMemo(() => {
if (pendingPermissionList.length === 0) {
return EMPTY_PENDING_PERMISSIONS;
}
return new Map(pendingPermissionList.map((permission) => [permission.key, permission]));
}, [pendingPermissionList]);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (!shouldUseOptimisticStream || !pendingCreate) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: pendingCreate.clientMessageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
...(pendingCreate.images && pendingCreate.images.length > 0
? { images: pendingCreate.images }
: {}),
},
];
}, [pendingCreate, shouldUseOptimisticStream]);
const mergedStreamItems = useMemo<StreamItem[]>(() => {
if (optimisticStreamItems.length === 0) {
return streamItems;
}
const optimistic = optimisticStreamItems[0];
if (!optimistic) {
return streamItems;
}
const alreadyHasOptimistic = streamItems.some(
(item) => item.kind === "user_message" && item.id === optimistic.id,
);
return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems];
}, [optimisticStreamItems, streamItems]);
useEffect(() => {
if (!shouldUseOptimisticStream || !pendingCreate) {
return;
}
const hasUserMessage = streamItems.some(
(item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId,
);
if (!hasUserMessage || !canFinalizePendingCreate) {
return;
}
const pendingImages = pendingCreate.images;
if (agentId && pendingImages && pendingImages.length > 0) {
setAgentStreamTail(serverId, (previous) => {
const current = previous.get(agentId);
if (!current) {
return previous;
}
const merged = mergePendingCreateImages({
streamItems: current,
clientMessageId: pendingCreate.clientMessageId,
images: pendingImages,
});
if (merged === current) {
return previous;
}
const next = new Map(previous);
next.set(agentId, merged);
return next;
});
}
markPendingCreateLifecycle({
draftId: pendingCreate.draftId,
lifecycle: "sent",
});
clearPendingCreate({ draftId: pendingCreate.draftId });
}, [
agentId,
canFinalizePendingCreate,
clearPendingCreate,
markPendingCreateLifecycle,
pendingCreate,
serverId,
setAgentStreamTail,
shouldUseOptimisticStream,
streamItems,
]);
return (
<AgentStreamView
ref={streamViewRef}
agentId={agent.id}
serverId={serverId}
agent={agent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
function AgentComposerSection({
agentId,
serverId,
isPaneFocused,
isArchivingCurrentAgent,
archivedAt,
initialCwd,
isSubmitLoading,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
onComposerHeightChange,
onMessageSent,
}: {
agentId?: string;
serverId: string;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
archivedAt: Date | null;
initialCwd: string;
isSubmitLoading: boolean;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
if (!agentId) {
return null;
}
if (archivedAt) {
return <ArchivedAgentCallout serverId={serverId} agentId={agentId} />;
}
if (isArchivingCurrentAgent) {
return null;
}
return (
<ActiveAgentComposer
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
initialCwd={initialCwd}
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
/>
);
}
function ActiveAgentComposer({
agentId,
serverId,
isPaneFocused,
initialCwd,
isSubmitLoading,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
onComposerHeightChange,
onMessageSent,
}: {
agentId: string;
serverId: string;
isPaneFocused: boolean;
initialCwd: string;
isSubmitLoading: boolean;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId,
}),
initialCwd,
});
return (
<Composer
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
attachments={agentInputDraft.attachments}
onChangeAttachments={agentInputDraft.setAttachments}
cwd={agentInputDraft.cwd}
clearDraft={agentInputDraft.clear}
autoFocus={isPaneFocused}
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
/>
);
}
function AgentSessionUnavailableState({
serverLabel,
connectionStatus,
+5 -11
View File
@@ -1,7 +1,7 @@
import { SquarePen } from "lucide-react-native";
import invariant from "tiny-invariant";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { usePaneContext } from "@/panels/pane-context";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -17,15 +17,9 @@ function useDraftPanelDescriptor() {
}
function DraftPanel() {
const {
serverId,
workspaceId,
tabId,
target,
isPaneFocused,
openFileInWorkspace,
retargetCurrentTab,
} = usePaneContext();
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
@@ -34,7 +28,7 @@ function DraftPanel() {
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
isPaneFocused={isPaneFocused}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath);
}}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { createPaneFocusContextValue } from "@/panels/pane-context";
describe("createPaneFocusContextValue", () => {
it("derives interactivity from both workspace and pane focus", () => {
expect(
createPaneFocusContextValue({
isWorkspaceFocused: true,
isPaneFocused: true,
}),
).toEqual({
isWorkspaceFocused: true,
isPaneFocused: true,
isInteractive: true,
});
expect(
createPaneFocusContextValue({
isWorkspaceFocused: false,
isPaneFocused: true,
}),
).toEqual({
isWorkspaceFocused: false,
isPaneFocused: true,
isInteractive: false,
});
});
});
+35 -2
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, type ReactNode } from "react";
import React, { createContext, useContext, type ReactNode } from "react";
import invariant from "tiny-invariant";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
@@ -6,7 +6,6 @@ export interface PaneContextValue {
serverId: string;
workspaceId: string;
tabId: string;
isPaneFocused: boolean;
target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
@@ -14,7 +13,25 @@ export interface PaneContextValue {
openFileInWorkspace(filePath: string): void;
}
export interface PaneFocusContextValue {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
isInteractive: boolean;
}
const PaneContext = createContext<PaneContextValue | null>(null);
const PaneFocusContext = createContext<PaneFocusContextValue | null>(null);
export function createPaneFocusContextValue(input: {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
}): PaneFocusContextValue {
return {
isWorkspaceFocused: input.isWorkspaceFocused,
isPaneFocused: input.isPaneFocused,
isInteractive: input.isWorkspaceFocused && input.isPaneFocused,
};
}
export function PaneProvider({
value,
@@ -26,8 +43,24 @@ export function PaneProvider({
return <PaneContext.Provider value={value}>{children}</PaneContext.Provider>;
}
export function PaneFocusProvider({
value,
children,
}: {
value: PaneFocusContextValue;
children: ReactNode;
}) {
return <PaneFocusContext.Provider value={value}>{children}</PaneFocusContext.Provider>;
}
export function usePaneContext(): PaneContextValue {
const value = useContext(PaneContext);
invariant(value, "PaneContext is required");
return value;
}
export function usePaneFocus(): PaneFocusContextValue {
const value = useContext(PaneFocusContext);
invariant(value, "PaneFocusContext is required");
return value;
}
+21 -5
View File
@@ -1,12 +1,13 @@
import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Terminal } from "lucide-react-native";
import { Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import invariant from "tiny-invariant";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { TerminalPane } from "@/components/terminal-pane";
import { usePaneContext } from "@/panels/pane-context";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
@@ -57,15 +58,28 @@ function useTerminalPanelDescriptor(
}
function TerminalPanel() {
const isFocused = useIsFocused();
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
const { serverId, workspaceId, target } = usePaneContext();
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const isGitCheckout = workspaceAuthority.ok
? workspaceAuthority.authority.workspace.projectKind === "git"
: false;
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const handleOpenFileExplorer = useCallback(() => {
if (!workspaceDirectory) {
return;
}
openFileExplorerForCheckout({
isCompact: true,
checkout: { serverId, cwd: workspaceDirectory, isGit: isGitCheckout },
});
}, [isGitCheckout, openFileExplorerForCheckout, serverId, workspaceDirectory]);
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
if (!isFocused) {
if (!isWorkspaceFocused) {
return <View style={{ flex: 1 }} />;
}
@@ -86,7 +100,9 @@ function TerminalPanel() {
serverId={serverId}
cwd={workspaceDirectory}
terminalId={target.terminalId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onOpenFileExplorer={handleOpenFileExplorer}
/>
);
}