feat(FN-1746): merge fusion/fn-1746
This commit is contained in:
@@ -1413,14 +1413,16 @@ GitHub PR and issue badges in the dashboard now have a dedicated real-time WebSo
|
||||
|
||||
### Frontend hook: `packages/dashboard/app/hooks/useBadgeWebSocket.ts`
|
||||
|
||||
Use `useBadgeWebSocket()` when a UI surface needs live badge snapshots for specific tasks.
|
||||
Use `useBadgeWebSocket(projectId?)` when a UI surface needs live badge snapshots for specific tasks.
|
||||
|
||||
- The hook uses a **shared singleton socket** so multiple `TaskCard` instances do not open duplicate WebSocket connections.
|
||||
- Subscribe with `subscribeToBadge(taskId)` only when the card is visible and already has `prInfo` and/or `issueInfo`.
|
||||
- Always pair subscriptions with `unsubscribeFromBadge(taskId)` on unmount or when the card leaves the viewport.
|
||||
- Treat websocket payloads as **timestamped badge snapshots**. Merge them with task data using freshness comparisons so stale cached websocket data does not override newer SSE/task state.
|
||||
- Preserve omitted fields on partial updates; only treat explicit `null` payloads as badge clears.
|
||||
- The frontend does not pass `projectId` to the hook — the project context is resolved server-side from the connection scope.
|
||||
- The hook accepts an optional `projectId` parameter which is included in the WebSocket URL (`/api/ws?projectId=...`). When `projectId` changes, the store resets and re-subscribes to all badges.
|
||||
- The `useMultiAgentLogs(taskIds, projectId?)` hook also supports `projectId` for multi-project SSE streams.
|
||||
- The `useTerminal(sessionId, projectId?)` hook includes `projectId` in the terminal WebSocket URL for multi-project support.
|
||||
|
||||
### Server-side expectations
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ function TaskCardComponent({
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const sendBackRef = useRef<HTMLDivElement>(null);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
|
||||
|
||||
// Touch gesture detection refs
|
||||
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
|
||||
@@ -26,10 +26,11 @@ interface TaskCardBadgeProps {
|
||||
issueInfo?: IssueInfo;
|
||||
updatedAt: string;
|
||||
isInViewport: boolean;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
function TaskCardBadgeComponent({ taskId, prInfo, issueInfo, updatedAt, isInViewport }: TaskCardBadgeProps) {
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
||||
function TaskCardBadgeComponent({ taskId, prInfo, issueInfo, updatedAt, isInViewport, projectId }: TaskCardBadgeProps) {
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
|
||||
const hasGitHubBadge = Boolean(prInfo || issueInfo);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,7 +43,7 @@ function TaskCardBadgeComponent({ taskId, prInfo, issueInfo, updatedAt, isInView
|
||||
return () => {
|
||||
unsubscribeFromBadge(taskId);
|
||||
};
|
||||
}, [hasGitHubBadge, isInViewport, subscribeToBadge, taskId, unsubscribeFromBadge]);
|
||||
}, [hasGitHubBadge, isInViewport, projectId, subscribeToBadge, taskId, unsubscribeFromBadge]);
|
||||
|
||||
const liveBadgeData = badgeUpdates.get(taskId);
|
||||
const livePrInfo = pickPreferredBadge<PrInfo>(
|
||||
|
||||
@@ -295,7 +295,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
|
||||
// Get the WebSocket connection for the active session
|
||||
const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect, onSessionInvalid } =
|
||||
useTerminal(activeTab?.sessionId ?? null);
|
||||
useTerminal(activeTab?.sessionId ?? null, projectId);
|
||||
|
||||
// Keep a ref to resize so the viewport-change effect can call it
|
||||
// without needing resize as a dependency (avoids ordering issues).
|
||||
|
||||
@@ -459,7 +459,7 @@ describe("TerminalModal", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseTerminal).toHaveBeenCalledWith("test-session-123");
|
||||
expect(mockUseTerminal).toHaveBeenCalledWith("test-session-123", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -267,4 +267,124 @@ describe("useBadgeWebSocket", () => {
|
||||
|
||||
expect(MockWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "unsubscribe", taskId: "FN-063" }));
|
||||
});
|
||||
|
||||
describe("projectId support", () => {
|
||||
it("includes projectId in WebSocket URL when provided", () => {
|
||||
const { result } = renderHook(() => useBadgeWebSocket("proj-123"));
|
||||
|
||||
act(() => {
|
||||
result.current.subscribeToBadge("FN-063");
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0].url).toContain("/api/ws");
|
||||
expect(MockWebSocket.instances[0].url).toContain("projectId=proj-123");
|
||||
});
|
||||
|
||||
it("connects without projectId when not provided", () => {
|
||||
const { result } = renderHook(() => useBadgeWebSocket());
|
||||
|
||||
act(() => {
|
||||
result.current.subscribeToBadge("FN-063");
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0].url).toBe(`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/api/ws`);
|
||||
});
|
||||
|
||||
it("reconnects with new projectId when projectId changes", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId?: string }) => useBadgeWebSocket(projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
// Subscribe to a badge
|
||||
act(() => {
|
||||
result.current.subscribeToBadge("FN-063");
|
||||
MockWebSocket.instances[0].emitOpen();
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances[0].url).toContain("projectId=proj-A");
|
||||
|
||||
// Update projectId to proj-B
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// Old socket should be closed
|
||||
expect(MockWebSocket.instances[0].close).toHaveBeenCalled();
|
||||
|
||||
// Wait for reconnect timer
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
|
||||
// New socket should connect with new projectId
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
expect(MockWebSocket.instances[1].url).toContain("projectId=proj-B");
|
||||
});
|
||||
|
||||
it("re-subscribes to badges after project change", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId?: string }) => useBadgeWebSocket(projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
// Subscribe to a badge
|
||||
act(() => {
|
||||
result.current.subscribeToBadge("FN-063");
|
||||
MockWebSocket.instances[0].emitOpen();
|
||||
});
|
||||
|
||||
// Record the subscribe message from initial connection
|
||||
const initialSubscribe = MockWebSocket.instances[0].sent.filter(
|
||||
(p) => p === JSON.stringify({ type: "subscribe", taskId: "FN-063" }),
|
||||
).length;
|
||||
|
||||
// Change project - this immediately creates a new socket (no timer needed)
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// The new socket is created synchronously, emit open so onopen fires
|
||||
act(() => {
|
||||
MockWebSocket.instances[1].emitOpen();
|
||||
});
|
||||
|
||||
// Subscribe should be sent again for the new connection
|
||||
const newSubscribe = MockWebSocket.instances[1].sent.filter(
|
||||
(p) => p === JSON.stringify({ type: "subscribe", taskId: "FN-063" }),
|
||||
).length;
|
||||
|
||||
expect(newSubscribe).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("clears badge updates on project change", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId?: string }) => useBadgeWebSocket(projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
// Subscribe and receive badge update
|
||||
act(() => {
|
||||
result.current.subscribeToBadge("FN-063");
|
||||
MockWebSocket.instances[0].emitOpen();
|
||||
MockWebSocket.instances[0].emitMessage({
|
||||
type: "badge:updated",
|
||||
taskId: "FN-063",
|
||||
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test PR", headBranch: "feat", baseBranch: "main", commentCount: 0 },
|
||||
timestamp: "2026-03-30T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.badgeUpdates.has("FN-063")).toBe(true);
|
||||
|
||||
// Change project
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// Wait for reconnect
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
|
||||
// Badge updates should be cleared
|
||||
expect(result.current.badgeUpdates.has("FN-063")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,4 +514,103 @@ describe("useMultiAgentLogs", () => {
|
||||
expect(result.current["FN-001"].entries[0].detail).toBe(longDetail);
|
||||
expect(result.current["FN-001"].entries[0].detail!.length).toBe(5000);
|
||||
});
|
||||
|
||||
describe("projectId support", () => {
|
||||
it("includes projectId in EventSource URL when provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"], "proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-001/logs/stream?projectId=proj-123");
|
||||
expect(urls).toContain("/api/tasks/FN-002/logs/stream?projectId=proj-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("includes projectId in fetchAgentLogs call when provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["FN-001"], "proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", "proj-123", { limit: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include projectId in URL when not provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-001/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates new EventSource when taskIds change with projectId", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds, projectId }: { taskIds: string[]; projectId?: string }) =>
|
||||
useMultiAgentLogs(taskIds, projectId),
|
||||
{ initialProps: { taskIds: ["FN-001"], projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-001/logs/stream?projectId=proj-A");
|
||||
});
|
||||
|
||||
const initialCount = MockEventSource.instances.length;
|
||||
|
||||
// Add a new taskId
|
||||
rerender({ taskIds: ["FN-001", "FN-002"], projectId: "proj-A" });
|
||||
|
||||
// Wait for new connection
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-002/logs/stream?projectId=proj-A");
|
||||
});
|
||||
expect(MockEventSource.instances.length).toBeGreaterThan(initialCount);
|
||||
});
|
||||
|
||||
it("fetches with correct projectId based on when effect runs", async () => {
|
||||
// This test verifies that projectId is used at the time the effect runs
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
// Render with projectId proj-A
|
||||
const { result: result1 } = renderHook(
|
||||
({ taskIds, projectId }: { taskIds: string[]; projectId?: string }) =>
|
||||
useMultiAgentLogs(taskIds, projectId),
|
||||
{ initialProps: { taskIds: ["FN-001"], projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => {
|
||||
expect(result1.current["FN-001"]).toBeDefined();
|
||||
});
|
||||
|
||||
// Capture calls made so far
|
||||
const initialCallCount = mockFetchAgentLogs.mock.calls.length;
|
||||
|
||||
// Create new hook instance with proj-B
|
||||
const { result: result2, rerender: rerender2 } = renderHook(
|
||||
({ taskIds, projectId }: { taskIds: string[]; projectId?: string }) =>
|
||||
useMultiAgentLogs(taskIds, projectId),
|
||||
{ initialProps: { taskIds: ["FN-001"], projectId: "proj-B" } },
|
||||
);
|
||||
|
||||
// Wait for fetch
|
||||
await waitFor(() => {
|
||||
expect(result2.current["FN-001"]).toBeDefined();
|
||||
});
|
||||
|
||||
// The new hook should have made a fetch with proj-B
|
||||
expect(mockFetchAgentLogs.mock.calls.length).toBeGreaterThan(initialCallCount);
|
||||
const lastCall = mockFetchAgentLogs.mock.calls.at(-1);
|
||||
expect(lastCall?.[1]).toBe("proj-B");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ class BadgeWebSocketStore {
|
||||
private reconnectDelayMs = 1_000;
|
||||
private shouldReconnect = false;
|
||||
private isConnected = false;
|
||||
private projectId: string | null = null;
|
||||
private snapshot: StoreSnapshot = {
|
||||
badgeUpdates: new Map(),
|
||||
isConnected: false,
|
||||
@@ -45,6 +46,34 @@ class BadgeWebSocketStore {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
setProjectId(projectId: string | null): void {
|
||||
if (this.projectId === projectId) return;
|
||||
|
||||
const hadSubscriptions = this.subscriptionsByTask.size > 0;
|
||||
// Collect all (hookId, taskId) pairs that were subscribed
|
||||
const previousSubscriptions: Array<{ hookId: string; taskId: string }> = [];
|
||||
for (const [taskId, subscribers] of this.subscriptionsByTask) {
|
||||
for (const hookId of subscribers) {
|
||||
previousSubscriptions.push({ hookId, taskId });
|
||||
}
|
||||
}
|
||||
|
||||
this.projectId = projectId;
|
||||
this.reset();
|
||||
|
||||
// Re-subscribe to all previous subscriptions after project change
|
||||
// This ensures badge subscriptions survive project switches
|
||||
// Note: we restore subscriptions BEFORE calling connect() so that
|
||||
// onopen will send the subscribe messages over the new socket
|
||||
if (hadSubscriptions) {
|
||||
for (const { hookId, taskId } of previousSubscriptions) {
|
||||
this.subscriptionsByTask.set(taskId, new Set([hookId]));
|
||||
}
|
||||
this.shouldReconnect = this.subscriptionsByTask.size > 0;
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
subscribeTask(hookId: string, taskId: string): void {
|
||||
const subscribers = this.subscriptionsByTask.get(taskId) ?? new Set<string>();
|
||||
const beforeSize = subscribers.size;
|
||||
@@ -101,7 +130,11 @@ class BadgeWebSocketStore {
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/api/ws`);
|
||||
let url = `${protocol}//${window.location.host}/api/ws`;
|
||||
if (this.projectId) {
|
||||
url += `?projectId=${encodeURIComponent(this.projectId)}`;
|
||||
}
|
||||
const ws = new WebSocket(url);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -216,7 +249,7 @@ function hasMessageField(message: BadgeUpdatedMessage, field: "prInfo" | "issueI
|
||||
return Object.prototype.hasOwnProperty.call(message, field);
|
||||
}
|
||||
|
||||
export function useBadgeWebSocket(): {
|
||||
export function useBadgeWebSocket(projectId?: string): {
|
||||
badgeUpdates: Map<string, BadgeSnapshot>;
|
||||
isConnected: boolean;
|
||||
subscribeToBadge: (taskId: string) => void;
|
||||
@@ -240,6 +273,11 @@ export function useBadgeWebSocket(): {
|
||||
badgeWebSocketStore.unsubscribeTask(hookIdRef.current!, taskId);
|
||||
}, []);
|
||||
|
||||
// Update project context when projectId changes
|
||||
useEffect(() => {
|
||||
badgeWebSocketStore.setProjectId(projectId ?? null);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
badgeWebSocketStore.cleanupHook(hookIdRef.current!);
|
||||
|
||||
@@ -42,7 +42,7 @@ interface InitState {
|
||||
* When task IDs are added or removed, connections are opened/closed accordingly.
|
||||
* When the component unmounts, all EventSources are closed to prevent memory leaks.
|
||||
*/
|
||||
export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogStateMap {
|
||||
// Store state per task
|
||||
const [stateMap, setStateMap] = useState<Record<string, InitState>>({});
|
||||
|
||||
@@ -67,8 +67,9 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Stable comparison of task IDs to prevent effect re-runs on every render
|
||||
// Stable comparison of task IDs and projectId to prevent effect re-runs on every render
|
||||
const taskIdsKey = taskIds.join(",");
|
||||
const stableKey = [taskIdsKey, projectId ?? ""].join("|");
|
||||
|
||||
// Main effect to manage connections
|
||||
useEffect(() => {
|
||||
@@ -152,8 +153,9 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
cancelled[taskId] = false;
|
||||
pendingLiveEntriesRef.current[taskId] = [];
|
||||
|
||||
// Open SSE connection immediately so rerenders cannot race a second setup
|
||||
const es = new EventSource(`/api/tasks/${taskId}/logs/stream`);
|
||||
// Build SSE URL with optional projectId for multi-project support
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/tasks/${taskId}/logs/stream${query}`);
|
||||
sources[taskId] = es;
|
||||
|
||||
const handleAgentLog = (e: MessageEvent) => {
|
||||
@@ -194,8 +196,8 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
es.addEventListener("agent:log", handleAgentLog);
|
||||
es.addEventListener("error", handleError);
|
||||
|
||||
// Fetch historical logs
|
||||
void fetchAgentLogs(taskId, undefined, { limit: MAX_LOG_ENTRIES })
|
||||
// Fetch historical logs with projectId
|
||||
void fetchAgentLogs(taskId, projectId, { limit: MAX_LOG_ENTRIES })
|
||||
.then((historical) => {
|
||||
if (cancelled[taskId]) return;
|
||||
|
||||
@@ -244,7 +246,7 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [taskIdsKey]); // Use stable string key instead of array reference
|
||||
}, [stableKey]); // Use stable key including projectId
|
||||
|
||||
// Close all connections on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -72,6 +72,57 @@ describe("useTerminal", () => {
|
||||
expect(MockWebSocket.instances[0].url).toContain("/api/terminal/ws?sessionId=test-session-123");
|
||||
});
|
||||
|
||||
describe("projectId support", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("includes projectId in WebSocket URL when provided", () => {
|
||||
const { result } = renderHook(() => useTerminal("test-session-123", "proj-456"));
|
||||
|
||||
expect(result.current.connectionStatus).toBe("connecting");
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0].url).toContain("/api/terminal/ws?sessionId=test-session-123");
|
||||
expect(MockWebSocket.instances[0].url).toContain("projectId=proj-456");
|
||||
});
|
||||
|
||||
it("does not include projectId in URL when not provided", () => {
|
||||
const { result } = renderHook(() => useTerminal("test-session-123"));
|
||||
|
||||
expect(result.current.connectionStatus).toBe("connecting");
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0].url).toBe(
|
||||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/api/terminal/ws?sessionId=test-session-123`,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates URL when projectId changes", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
|
||||
useTerminal(sessionId, projectId),
|
||||
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
expect(MockWebSocket.instances[0].url).toContain("projectId=proj-A");
|
||||
|
||||
// Change projectId
|
||||
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
|
||||
|
||||
// Wait for reconnect
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// New WebSocket should have new projectId
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
expect(MockWebSocket.instances[1].url).toContain("projectId=proj-B");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports connected status when the websocket opens", () => {
|
||||
const { result } = renderHook(() => useTerminal("test-session-123"));
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ function createEmptyBuffer(): BufferedMessages {
|
||||
* }, [onData]);
|
||||
* ```
|
||||
*/
|
||||
export function useTerminal(sessionId: string | null): UseTerminalReturn {
|
||||
export function useTerminal(sessionId: string | null, projectId?: string): UseTerminalReturn {
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("disconnected");
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -213,9 +213,12 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
|
||||
isManualCloseRef.current = false;
|
||||
setConnectionStatus("connecting");
|
||||
|
||||
// Build WebSocket URL
|
||||
// Build WebSocket URL with optional projectId for multi-project support
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/terminal/ws?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
let wsUrl = `${protocol}//${window.location.host}/api/terminal/ws?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
if (projectId) {
|
||||
wsUrl += `&projectId=${encodeURIComponent(projectId)}`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
@@ -338,7 +341,7 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
|
||||
ws.onerror = () => {
|
||||
// Errors are handled by onclose
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, projectId]);
|
||||
|
||||
// Manual reconnect
|
||||
const reconnect = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user