Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions src/browser/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Tests for browser API client
* Tests the invokeIPC function to ensure it behaves consistently with Electron's ipcRenderer.invoke()
*/

import { describe, test, expect } from "bun:test";

// Helper to create a mock fetch that returns a specific response
function createMockFetch(responseData: unknown) {
return () => {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(responseData),
} as Response);
};
}

interface InvokeResponse<T> {
success: boolean;
data?: T;
error?: unknown;
}

// Helper to create invokeIPC function with mocked fetch
function createInvokeIPC(
mockFetch: (url: string, init?: RequestInit) => Promise<Response>
): <T>(channel: string, ...args: unknown[]) => Promise<T> {
const API_BASE = "http://localhost:3000";

async function invokeIPC<T>(channel: string, ...args: unknown[]): Promise<T> {
const response = await mockFetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ args }),
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const result = (await response.json()) as InvokeResponse<T>;

// Return the result as-is - let the caller handle success/failure
// This matches the behavior of Electron's ipcRenderer.invoke() which doesn't throw on error
if (!result.success) {
return result as T;
}

// Success - unwrap and return the data
return result.data as T;
}

return invokeIPC;
}

describe("Browser API invokeIPC", () => {
test("should return error object on failure (matches Electron behavior)", async () => {
const mockFetch = createMockFetch({
success: false,
error: "fatal: contains modified or untracked files",
});

const invokeIPC = createInvokeIPC(mockFetch);

// Fixed behavior: invokeIPC returns error object instead of throwing
// This matches Electron's ipcRenderer.invoke() which never throws on error
const result = await invokeIPC<{ success: boolean; error?: string }>(
"WORKSPACE_REMOVE",
"test-workspace",
{ force: false }
);

expect(result).toEqual({
success: false,
error: "fatal: contains modified or untracked files",
});
});

test("should return success data on success", async () => {
const mockFetch = createMockFetch({
success: true,
data: { someData: "value" },
});

const invokeIPC = createInvokeIPC(mockFetch);

const result = await invokeIPC("WORKSPACE_REMOVE", "test-workspace", { force: true });

expect(result).toEqual({ someData: "value" });
});

test("should throw on HTTP errors", async () => {
const mockFetch = () => {
return Promise.resolve({
ok: false,
status: 500,
} as Response);
};

const invokeIPC = createInvokeIPC(mockFetch);

// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(invokeIPC("WORKSPACE_REMOVE", "test-workspace", { force: false })).rejects.toThrow(
"HTTP error! status: 500"
);
});

test("should return structured error objects as-is", async () => {
const structuredError = {
type: "STREAMING_IN_PROGRESS",
message: "Cannot send message while streaming",
workspaceId: "test-workspace",
};

const mockFetch = createMockFetch({
success: false,
error: structuredError,
});

const invokeIPC = createInvokeIPC(mockFetch);

const result = await invokeIPC("WORKSPACE_SEND_MESSAGE", "test-workspace", {
role: "user",
content: [{ type: "text", text: "test" }],
});

// Structured errors should be returned as-is
expect(result).toEqual({
success: false,
error: structuredError,
});
});
});
10 changes: 3 additions & 7 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,10 @@ async function invokeIPC<T>(channel: string, ...args: unknown[]): Promise<T> {

const result = (await response.json()) as InvokeResponse<T>;

// Return the result as-is - let the caller handle success/failure
// This matches the behavior of Electron's ipcRenderer.invoke() which doesn't throw on error
if (!result.success) {
// Failed response - check if it's a structured error or simple string
if (typeof result.error === "object" && result.error !== null) {
// Structured error (e.g., SendMessageError) - return as Result<T, E> for caller to handle
return result as T;
}
// Simple string error - throw it
throw new Error(typeof result.error === "string" ? result.error : "Unknown error");
return result as T;
}

// Success - unwrap and return the data
Expand Down
24 changes: 19 additions & 5 deletions src/hooks/useAutoCompactContinue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,25 @@ export function useAutoCompactContinue() {

// Build options and send message directly
const options = buildSendMessageOptions(workspaceId);
window.api.workspace.sendMessage(workspaceId, continueMessage, options).catch((error) => {
console.error("Failed to send continue message:", error);
// If sending failed, remove from processed set to allow retry
processedMessageIds.current.delete(idForGuard);
});
void (async () => {
try {
const result = await window.api.workspace.sendMessage(
workspaceId,
continueMessage,
options
);
// Check if send failed (browser API returns error object, not throw)
if (!result.success && "error" in result) {
console.error("Failed to send continue message:", result.error);
// If sending failed, remove from processed set to allow retry
processedMessageIds.current.delete(idForGuard);
}
} catch (error) {
// Handle network/parsing errors (HTTP errors, etc.)
console.error("Failed to send continue message:", error);
processedMessageIds.current.delete(idForGuard);
}
})();
}
};

Expand Down
106 changes: 59 additions & 47 deletions src/hooks/useWorkspaceManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,63 +117,75 @@ export function useWorkspaceManagement({
workspaceId: string,
options?: { force?: boolean }
): Promise<{ success: boolean; error?: string }> => {
const result = await window.api.workspace.remove(workspaceId, options);
if (result.success) {
// Clean up workspace-specific localStorage keys
deleteWorkspaceStorage(workspaceId);

// Backend has already updated the config - reload projects to get updated state
const projectsList = await window.api.projects.list();
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
onProjectsUpdate(loadedProjects);

// Reload workspace metadata
await loadWorkspaceMetadata();

// Clear selected workspace if it was removed
if (selectedWorkspace?.workspaceId === workspaceId) {
onSelectedWorkspaceUpdate(null);
try {
const result = await window.api.workspace.remove(workspaceId, options);
if (result.success) {
// Clean up workspace-specific localStorage keys
deleteWorkspaceStorage(workspaceId);

// Backend has already updated the config - reload projects to get updated state
const projectsList = await window.api.projects.list();
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
onProjectsUpdate(loadedProjects);

// Reload workspace metadata
await loadWorkspaceMetadata();

// Clear selected workspace if it was removed
if (selectedWorkspace?.workspaceId === workspaceId) {
onSelectedWorkspaceUpdate(null);
}
return { success: true };
} else {
console.error("Failed to remove workspace:", result.error);
return { success: false, error: result.error };
}
return { success: true };
} else {
console.error("Failed to remove workspace:", result.error);
return { success: false, error: result.error };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to remove workspace:", errorMessage);
return { success: false, error: errorMessage };
}
},
[loadWorkspaceMetadata, onProjectsUpdate, onSelectedWorkspaceUpdate, selectedWorkspace]
);

const renameWorkspace = useCallback(
async (workspaceId: string, newName: string): Promise<{ success: boolean; error?: string }> => {
const result = await window.api.workspace.rename(workspaceId, newName);
if (result.success) {
// Backend has already updated the config - reload projects to get updated state
const projectsList = await window.api.projects.list();
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
onProjectsUpdate(loadedProjects);

// Reload workspace metadata
await loadWorkspaceMetadata();

// Update selected workspace if it was renamed
if (selectedWorkspace?.workspaceId === workspaceId) {
const newWorkspaceId = result.data.newWorkspaceId;

// Get updated workspace metadata from backend
const newMetadata = await window.api.workspace.getInfo(newWorkspaceId);
if (newMetadata) {
onSelectedWorkspaceUpdate({
projectPath: selectedWorkspace.projectPath,
projectName: newMetadata.projectName,
namedWorkspacePath: newMetadata.namedWorkspacePath,
workspaceId: newWorkspaceId,
});
try {
const result = await window.api.workspace.rename(workspaceId, newName);
if (result.success) {
// Backend has already updated the config - reload projects to get updated state
const projectsList = await window.api.projects.list();
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
onProjectsUpdate(loadedProjects);

// Reload workspace metadata
await loadWorkspaceMetadata();

// Update selected workspace if it was renamed
if (selectedWorkspace?.workspaceId === workspaceId) {
const newWorkspaceId = result.data.newWorkspaceId;

// Get updated workspace metadata from backend
const newMetadata = await window.api.workspace.getInfo(newWorkspaceId);
if (newMetadata) {
onSelectedWorkspaceUpdate({
projectPath: selectedWorkspace.projectPath,
projectName: newMetadata.projectName,
namedWorkspacePath: newMetadata.namedWorkspacePath,
workspaceId: newWorkspaceId,
});
}
}
return { success: true };
} else {
console.error("Failed to rename workspace:", result.error);
return { success: false, error: result.error };
}
return { success: true };
} else {
console.error("Failed to rename workspace:", result.error);
return { success: false, error: result.error };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to rename workspace:", errorMessage);
return { success: false, error: errorMessage };
}
},
[loadWorkspaceMetadata, onProjectsUpdate, onSelectedWorkspaceUpdate, selectedWorkspace]
Expand Down