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:
Mohamed Boudra
2026-01-05 21:27:35 +07:00
co-authored by Claude Opus 4.5
parent 8c33df472f
commit 348242541c
9 changed files with 486 additions and 65 deletions
+231
View 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 };
@@ -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,
};