mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(git-diff): add React Query with pull-to-refresh and cleaner diff display
- Add useGitDiffQuery hook using React Query for caching and revalidation - Add sendRpcRequest utility for promisified WebSocket RPC with requestId matching - Add requestId to git_diff_request/response for proper request correlation - Enable pull-to-refresh via RefreshControl in GitDiffPane - Auto-revalidate when sidebar opens with "changes" tab active - Clean up diff display: remove metadata noise, strip +/- prefixes - Default both sidebars open on web platform 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { View, Text, Pressable, Platform } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, {
|
||||
@@ -13,13 +13,12 @@ import {
|
||||
useExplorerSidebarStore,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ViewMode,
|
||||
} from "@/stores/explorer-sidebar-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
|
||||
type ViewMode = "list" | "grid";
|
||||
|
||||
type ExplorerTab = "changes" | "files";
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
@@ -30,7 +29,7 @@ interface ExplorerSidebarProps {
|
||||
export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isOpen, activeTab, width, close, setActiveTab, setWidth } =
|
||||
const { isOpen, activeTab, width, viewMode, close, setActiveTab, setWidth, setViewMode } =
|
||||
useExplorerSidebarStore();
|
||||
const {
|
||||
translateX,
|
||||
@@ -45,9 +44,6 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
// File explorer view mode state
|
||||
const [fileViewMode, setFileViewMode] = useState<ViewMode>("list");
|
||||
|
||||
// For resize drag, track the starting width
|
||||
const startWidthRef = useRef(width);
|
||||
const resizeWidth = useSharedValue(width);
|
||||
@@ -174,8 +170,8 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
onClose={handleClose}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
fileViewMode={fileViewMode}
|
||||
onFileViewModeChange={setFileViewMode}
|
||||
fileViewMode={viewMode}
|
||||
onFileViewModeChange={setViewMode}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -209,8 +205,8 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
onClose={handleClose}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
fileViewMode={fileViewMode}
|
||||
onFileViewModeChange={setFileViewMode}
|
||||
fileViewMode={viewMode}
|
||||
onFileViewModeChange={setViewMode}
|
||||
isMobile={false}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { View, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { View, Text, ActivityIndicator, Platform, RefreshControl } from "react-native";
|
||||
import { Gesture, GestureDetector, ScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useGitDiffQuery } from "@/hooks/use-git-diff-query";
|
||||
|
||||
interface ParsedDiffFile {
|
||||
path: string;
|
||||
@@ -33,13 +33,28 @@ function parseDiff(diffText: string): ParsedDiffFile[] {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("index ")) {
|
||||
parsedLines.push({ type: "header", content: line });
|
||||
// Skip metadata lines - they're noise in the UI
|
||||
// - First line (a/... b/...) - already shown in file header
|
||||
// - index ... - git hash info, not useful
|
||||
// - --- a/... and +++ b/... - file markers, redundant
|
||||
if (i === 0) continue;
|
||||
if (line.startsWith("index ")) continue;
|
||||
if (line.startsWith("--- ")) continue;
|
||||
if (line.startsWith("+++ ")) continue;
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
// Extract just the line numbers portion from @@ -x,y +x,y @@ context
|
||||
const hunkMatch = line.match(/^(@@ .+? @@)/);
|
||||
const hunkHeader = hunkMatch ? hunkMatch[1] : line;
|
||||
parsedLines.push({ type: "header", content: hunkHeader });
|
||||
} else if (line.startsWith("+")) {
|
||||
parsedLines.push({ type: "add", content: line });
|
||||
parsedLines.push({ type: "add", content: line.slice(1) });
|
||||
} else if (line.startsWith("-")) {
|
||||
parsedLines.push({ type: "remove", content: line });
|
||||
} else {
|
||||
parsedLines.push({ type: "remove", content: line.slice(1) });
|
||||
} else if (line.startsWith(" ")) {
|
||||
parsedLines.push({ type: "context", content: line.slice(1) });
|
||||
} else if (line.length > 0) {
|
||||
// Non-empty lines without prefix (rare, but handle gracefully)
|
||||
parsedLines.push({ type: "context", content: line });
|
||||
}
|
||||
}
|
||||
@@ -56,9 +71,12 @@ interface GitDiffPaneProps {
|
||||
}
|
||||
|
||||
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const hasRequestedRef = useRef<string | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const { closeGestureRef } = useExplorerSidebarAnimation();
|
||||
const { diff, isLoading, isFetching, isError, error, refresh } = useGitDiffQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -75,42 +93,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
state.sessions[serverId]?.agents?.get(agentId)
|
||||
);
|
||||
|
||||
const diffText = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.gitDiffs?.get(agentId)
|
||||
);
|
||||
|
||||
const requestGitDiff = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.methods?.requestGitDiff
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId || !requestGitDiff) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate requests for the same agentId
|
||||
if (hasRequestedRef.current === agentId) {
|
||||
return;
|
||||
}
|
||||
hasRequestedRef.current = agentId;
|
||||
|
||||
setIsLoading(true);
|
||||
requestGitDiff(agentId);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [agentId, requestGitDiff]);
|
||||
|
||||
useEffect(() => {
|
||||
if (diffText !== undefined) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [diffText]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
@@ -119,12 +101,23 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const isError = diffText?.startsWith("Error:");
|
||||
const parsedFiles = isError || !diffText ? [] : parseDiff(diffText);
|
||||
const parsedFiles = isError || !diff ? [] : parseDiff(diff);
|
||||
const hasChanges = parsedFiles.length > 0;
|
||||
const errorMessage = isError && error instanceof Error ? error.message : null;
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching && !isLoading}
|
||||
onRefresh={refresh}
|
||||
tintColor={theme.colors.mutedForeground}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
@@ -132,7 +125,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
</View>
|
||||
) : isError ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{diffText}</Text>
|
||||
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
|
||||
</View>
|
||||
) : !hasChanges ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
|
||||
62
packages/app/src/hooks/use-git-diff-query.ts
Normal file
62
packages/app/src/hooks/use-git-diff-query.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { sendRpcRequest } from "@/lib/send-rpc-request";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
|
||||
const GIT_DIFF_STALE_TIME = 30_000;
|
||||
|
||||
function gitDiffQueryKey(serverId: string, agentId: string) {
|
||||
return ["gitDiff", serverId, agentId] as const;
|
||||
}
|
||||
|
||||
interface UseGitDiffQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
const { isOpen, activeTab } = useExplorerSidebarStore();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!ws) {
|
||||
throw new Error("WebSocket not available");
|
||||
}
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "git_diff_request",
|
||||
agentId,
|
||||
});
|
||||
return response.diff;
|
||||
},
|
||||
enabled: !!ws && ws.isConnected && !!agentId,
|
||||
staleTime: GIT_DIFF_STALE_TIME,
|
||||
});
|
||||
|
||||
// Revalidate when sidebar opens with "changes" tab active
|
||||
useEffect(() => {
|
||||
if (!isOpen || activeTab !== "changes" || !agentId) {
|
||||
return;
|
||||
}
|
||||
// Invalidate to trigger background refetch (shows stale data while fetching)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
});
|
||||
}, [isOpen, activeTab, serverId, agentId, queryClient]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
return {
|
||||
diff: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
231
packages/app/src/lib/send-rpc-request.ts
Normal file
231
packages/app/src/lib/send-rpc-request.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type {
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/server/messages";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
// ============================================================================
|
||||
// Type-level utilities for automatic request→response type inference
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract the payload type from a message.
|
||||
*/
|
||||
type PayloadOf<TMessage> = TMessage extends { payload: infer P } ? P : never;
|
||||
|
||||
/**
|
||||
* All request message types that can be used with sendRpcRequest.
|
||||
* These are inbound messages that end with `_request`.
|
||||
*/
|
||||
type RpcRequestMessage = Extract<SessionInboundMessage, { type: `${string}_request` }>;
|
||||
|
||||
/**
|
||||
* Extract the type literal from a request message.
|
||||
*/
|
||||
type RpcRequestType = RpcRequestMessage["type"];
|
||||
|
||||
/**
|
||||
* Override mapping for requests that don't follow the standard `_request` → `_response` pattern.
|
||||
*/
|
||||
interface ResponseTypeOverrides {
|
||||
create_agent_request: "agent_state";
|
||||
refresh_agent_request: "agent_state";
|
||||
initialize_agent_request: "initialize_agent_request";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert request type string to response type string.
|
||||
* - First checks override mapping for non-standard patterns
|
||||
* - Falls back to standard `*_request` → `*_response` conversion
|
||||
*/
|
||||
type RequestToResponseType<T extends string> = T extends keyof ResponseTypeOverrides
|
||||
? ResponseTypeOverrides[T]
|
||||
: T extends `${infer Base}_request`
|
||||
? `${Base}_response`
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Given a request type string, get the response message type.
|
||||
*/
|
||||
type ResponseMessageFor<T extends RpcRequestType> = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: RequestToResponseType<T> }
|
||||
>;
|
||||
|
||||
/**
|
||||
* Given a request type string, get the payload type of the response.
|
||||
*/
|
||||
type ResponsePayloadFor<T extends RpcRequestType> = PayloadOf<ResponseMessageFor<T>>;
|
||||
|
||||
/**
|
||||
* Given a request type string, get the request message type (without requestId).
|
||||
*/
|
||||
type RequestInputFor<T extends RpcRequestType> = Omit<
|
||||
Extract<SessionInboundMessage, { type: T }>,
|
||||
"requestId"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Infer the request type from a request object.
|
||||
* This enables TypeScript to narrow based on the `type` property.
|
||||
*/
|
||||
type InferRequestType<TRequest> = TRequest extends { type: infer T extends RpcRequestType }
|
||||
? T
|
||||
: never;
|
||||
|
||||
// ============================================================================
|
||||
// Runtime configuration
|
||||
// ============================================================================
|
||||
|
||||
interface SendRpcRequestOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
|
||||
class RpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: "timeout" | "response_error" | "disconnected"
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RpcError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps request types to their corresponding response types at runtime.
|
||||
*/
|
||||
const RESPONSE_TYPE_MAP: Record<string, SessionOutboundMessage["type"]> = {
|
||||
git_diff_request: "git_diff_response",
|
||||
file_explorer_request: "file_explorer_response",
|
||||
file_download_token_request: "file_download_token_response",
|
||||
git_repo_info_request: "git_repo_info_response",
|
||||
list_provider_models_request: "list_provider_models_response",
|
||||
list_conversations_request: "list_conversations_response",
|
||||
list_persisted_agents_request: "list_persisted_agents_response",
|
||||
create_agent_request: "agent_state",
|
||||
refresh_agent_request: "agent_state",
|
||||
initialize_agent_request: "initialize_agent_request",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Send an RPC request over WebSocket and wait for the matching response.
|
||||
*
|
||||
* Features:
|
||||
* - Auto-generates and injects requestId into the request
|
||||
* - Matches response by requestId in payload
|
||||
* - Fully typed: response type is inferred from request type
|
||||
* - Throws on timeout or if response contains an error field
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const response = await sendRpcRequest(ws, {
|
||||
* type: "git_diff_request",
|
||||
* agentId: "abc123",
|
||||
* });
|
||||
* // response is typed as { agentId: string; diff: string; error: string | null }
|
||||
* ```
|
||||
*/
|
||||
export function sendRpcRequest<
|
||||
const TRequest extends RequestInputFor<RpcRequestType>
|
||||
>(
|
||||
ws: UseWebSocketReturn,
|
||||
request: TRequest,
|
||||
options?: SendRpcRequestOptions
|
||||
): Promise<ResponsePayloadFor<InferRequestType<TRequest>>> {
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const requestId = generateMessageId();
|
||||
const requestType = (request as { type: string }).type;
|
||||
const responseType = RESPONSE_TYPE_MAP[requestType];
|
||||
|
||||
if (!responseType) {
|
||||
return Promise.reject(
|
||||
new RpcError(`Unknown request type: ${requestType}`, "response_error")
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
};
|
||||
|
||||
const settle = <U>(fn: () => U): U | undefined => {
|
||||
if (settled) {
|
||||
return undefined;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
return fn();
|
||||
};
|
||||
|
||||
// Subscribe to the response type
|
||||
unsubscribe = ws.on(responseType, (message) => {
|
||||
const payload = (message as { payload?: unknown }).payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
|
||||
// Match by requestId
|
||||
if (payloadRecord.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for error in response
|
||||
if (
|
||||
typeof payloadRecord.error === "string" &&
|
||||
payloadRecord.error.length > 0
|
||||
) {
|
||||
settle(() =>
|
||||
reject(new RpcError(payloadRecord.error as string, "response_error"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
settle(() => resolve(payload as ResponsePayloadFor<InferRequestType<TRequest>>));
|
||||
});
|
||||
|
||||
// Set up timeout
|
||||
if (timeoutMs > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
settle(() =>
|
||||
reject(
|
||||
new RpcError(`RPC request timed out after ${timeoutMs}ms`, "timeout")
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Send the request with the generated requestId
|
||||
const fullRequest = {
|
||||
...request,
|
||||
requestId,
|
||||
} as SessionInboundMessage;
|
||||
|
||||
ws.send({
|
||||
type: "session",
|
||||
message: fullRequest,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { RpcError };
|
||||
export type { RpcRequestType, ResponsePayloadFor, RequestInputFor };
|
||||
128
packages/app/src/lib/send-rpc-request.typetest.ts
Normal file
128
packages/app/src/lib/send-rpc-request.typetest.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Type verification tests for sendRpcRequest
|
||||
* Run `npm run typecheck` - this file should compile with the expected errors marked by @ts-expect-error
|
||||
*/
|
||||
import { sendRpcRequest } from "./send-rpc-request";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
|
||||
declare const ws: UseWebSocketReturn;
|
||||
|
||||
// ============================================================================
|
||||
// Test 1: Git diff request - response should be fully typed
|
||||
// ============================================================================
|
||||
async function testGitDiff() {
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "git_diff_request",
|
||||
agentId: "test-agent",
|
||||
});
|
||||
|
||||
// ✅ These should work - fields exist on response
|
||||
const agentId: string = response.agentId;
|
||||
const diff: string = response.diff;
|
||||
const error: string | null = response.error;
|
||||
console.log(agentId, diff, error);
|
||||
|
||||
// ❌ These should error
|
||||
// @ts-expect-error - 'nonExistent' does not exist on git_diff_response payload
|
||||
response.nonExistent;
|
||||
|
||||
// @ts-expect-error - diff is string, not number
|
||||
const wrongType: number = response.diff;
|
||||
console.log(wrongType);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 2: File explorer request - response should be fully typed
|
||||
// ============================================================================
|
||||
async function testFileExplorer() {
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "file_explorer_request",
|
||||
agentId: "test-agent",
|
||||
path: ".",
|
||||
mode: "list",
|
||||
});
|
||||
|
||||
// ✅ These should work
|
||||
const agentId: string = response.agentId;
|
||||
const mode: "list" | "file" = response.mode;
|
||||
const path: string = response.path;
|
||||
console.log(agentId, mode, path);
|
||||
|
||||
// ✅ Directory is optional/nullable
|
||||
if (response.directory) {
|
||||
const entries = response.directory.entries;
|
||||
console.log(entries);
|
||||
}
|
||||
|
||||
// ❌ Should error
|
||||
// @ts-expect-error - 'fakeField' does not exist
|
||||
response.fakeField;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 3: Request must include required fields
|
||||
// ============================================================================
|
||||
async function testRequiredFields() {
|
||||
// ✅ Valid calls - all required fields present
|
||||
await sendRpcRequest(ws, { type: "git_diff_request", agentId: "test" });
|
||||
await sendRpcRequest(ws, { type: "file_explorer_request", agentId: "x", path: ".", mode: "list" });
|
||||
|
||||
// Note: Missing fields would cause compile errors, but we can't use @ts-expect-error
|
||||
// on the call itself because the `const` generic infers the literal object type.
|
||||
// The type system validates at the constraint level, not the call level.
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 4: File download token request
|
||||
// ============================================================================
|
||||
async function testFileDownloadToken() {
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "file_download_token_request",
|
||||
agentId: "test-agent",
|
||||
path: "/file.txt",
|
||||
});
|
||||
|
||||
// ✅ These should work
|
||||
const token: string | null = response.token;
|
||||
const agentId: string = response.agentId;
|
||||
console.log(token, agentId);
|
||||
|
||||
// ❌ Should error
|
||||
// @ts-expect-error - invalid field
|
||||
response.notAField;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 5: Verify template literal type derivation works
|
||||
// ============================================================================
|
||||
async function testTemplateLiteralDerivation() {
|
||||
// The response type should be automatically derived from request type
|
||||
// "git_diff_request" → "git_diff_response" → payload type
|
||||
|
||||
const gitDiff = await sendRpcRequest(ws, { type: "git_diff_request", agentId: "a" });
|
||||
const fileExplorer = await sendRpcRequest(ws, { type: "file_explorer_request", agentId: "a", path: ".", mode: "list" });
|
||||
const downloadToken = await sendRpcRequest(ws, { type: "file_download_token_request", agentId: "a", path: "." });
|
||||
|
||||
// Each response should have its own distinct type
|
||||
console.log(gitDiff.diff); // string
|
||||
console.log(fileExplorer.directory); // object | null
|
||||
console.log(downloadToken.token); // string | null
|
||||
|
||||
// ❌ Cross-type access should fail
|
||||
// @ts-expect-error - gitDiff doesn't have 'directory'
|
||||
gitDiff.directory;
|
||||
|
||||
// @ts-expect-error - fileExplorer doesn't have 'diff'
|
||||
fileExplorer.diff;
|
||||
|
||||
// @ts-expect-error - downloadToken doesn't have 'diff'
|
||||
downloadToken.diff;
|
||||
}
|
||||
|
||||
export {
|
||||
testGitDiff,
|
||||
testFileExplorer,
|
||||
testRequiredFields,
|
||||
testFileDownloadToken,
|
||||
testTemplateLiteralDerivation,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
type ExplorerTab = "changes" | "files";
|
||||
export type ViewMode = "list" | "grid";
|
||||
@@ -10,6 +11,8 @@ export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
|
||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||
export const MAX_EXPLORER_SIDEBAR_WIDTH = 800;
|
||||
|
||||
const DEFAULT_OPEN = Platform.OS === "web";
|
||||
|
||||
interface ExplorerSidebarState {
|
||||
isOpen: boolean;
|
||||
activeTab: ExplorerTab;
|
||||
@@ -32,7 +35,7 @@ function clampWidth(width: number): number {
|
||||
export const useExplorerSidebarStore = create<ExplorerSidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isOpen: false,
|
||||
isOpen: DEFAULT_OPEN,
|
||||
activeTab: "changes",
|
||||
width: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
viewMode: "list",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
interface SidebarState {
|
||||
isOpen: boolean;
|
||||
@@ -9,10 +10,12 @@ interface SidebarState {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_OPEN = Platform.OS === "web";
|
||||
|
||||
export const useSidebarStore = create<SidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isOpen: false,
|
||||
isOpen: DEFAULT_OPEN,
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
|
||||
@@ -450,6 +450,7 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
||||
export const GitDiffRequestSchema = z.object({
|
||||
type: z.literal("git_diff_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
const FileExplorerEntrySchema = z.object({
|
||||
@@ -736,6 +737,7 @@ export const GitDiffResponseSchema = z.object({
|
||||
agentId: z.string(),
|
||||
diff: z.string(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -840,7 +840,7 @@ export class Session {
|
||||
break;
|
||||
|
||||
case "git_diff_request":
|
||||
await this.handleGitDiffRequest(msg.agentId);
|
||||
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "file_explorer_request":
|
||||
@@ -1967,7 +1967,7 @@ export class Session {
|
||||
/**
|
||||
* Handle git diff request for an agent
|
||||
*/
|
||||
private async handleGitDiffRequest(agentId: string): Promise<void> {
|
||||
private async handleGitDiffRequest(agentId: string, requestId?: string): Promise<void> {
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Handling git diff request for agent ${agentId}`
|
||||
);
|
||||
@@ -1983,6 +1983,7 @@ export class Session {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: `Agent not found: ${agentId}`,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -1998,6 +1999,7 @@ export class Session {
|
||||
agentId,
|
||||
diff: stdout,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2015,6 +2017,7 @@ export class Session {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: error.message,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user