diff --git a/.agents/skills/debug-logs/SKILL.md b/.agents/skills/debug-logs/SKILL.md new file mode 100644 index 0000000000..1e3dcfdcfa --- /dev/null +++ b/.agents/skills/debug-logs/SKILL.md @@ -0,0 +1,98 @@ +--- +name: debug-logs +description: Add temporary debug logs to investigate issues. Use when the user needs to trace runtime behavior in the frontend or backend. Debug logs must be stripped before creating a PR. +--- + +# Debug Logs + +Add temporary debug logs to investigate runtime issues. These logs are **never merged** — they must be removed before creating a PR. + +## Rules + +1. **All debug logs are temporary.** Strip them before running `/commit` or `/pr`. +2. Use a consistent, searchable prefix so logs can be found and removed easily. +3. Log object fields **inline** — do not log raw objects (they render as `Object` in the browser console and require expanding). + +## Frontend (TypeScript) + +- **Level:** `console.log` — not `console.debug` (hidden by default) or `console.warn` (noisy). +- **Prefix:** `[WS-DEBUG]` (or another `[AREA-DEBUG]` prefix agreed with the user). +- **Format:** Log fields inline as key-value pairs so they are visible without expanding. + +### ✅ Correct + +```typescript +console.log("[WS-DEBUG] subscribeSession", { + sessionId, + refCount: current + 1, + sentMessage: shouldSend, +}); +``` + +Console output: +``` +[WS-DEBUG] subscribeSession {sessionId: 'abc', refCount: 2, sentMessage: true} +``` + +### ❌ Wrong — raw object renders as `Object` + +```typescript +console.log("[WS-DEBUG] subscribeSession", session); +// Output: [WS-DEBUG] subscribeSession Object ← useless without expanding +``` + +### ❌ Wrong — wrong log level + +```typescript +console.warn("[WS-DEBUG] subscribeSession", { sessionId }); // ← use console.log +console.debug("[WS-DEBUG] subscribeSession", { sessionId }); // ← hidden by default +``` + +## Backend (Go) + +- **Level:** `WARN` — stands out from normal `DEBUG`/`INFO` output without being an error. +- **Prefix:** `[DEBUG]` (or another `[AREA-DEBUG]` prefix agreed with the user). +- **Method:** Use the structured logger: `s.logger.Warn("[DEBUG] description", "key", value, ...)`. + +### ✅ Correct + +```go +s.logger.Warn("[DEBUG] handleTaskMoved entering", + "task_id", taskID, + "session_id", sessionID, + "from_step", fromStepID, + "to_step", toStepID, +) +``` + +### ❌ Wrong — wrong level + +```go +s.logger.Debug("[DEBUG] handleTaskMoved", "task_id", taskID) // ← lost in noise +s.logger.Error("[DEBUG] handleTaskMoved", "task_id", taskID) // ← triggers alerts +``` + +## Workflow + +1. **Add debug logs** to the relevant code paths. Do not commit them — keep them as unstaged changes. +2. **Let the user test** the app and report back with console/log output. +3. **Iterate** — add, move, or refine logs as needed based on findings. Still no commits. +4. **Fix the issue** once the root cause is identified. +5. **Strip all debug logs** before committing the fix. Only commit the actual fix. + +## Stripping Debug Logs + +When the issue is fixed and the user asks to commit, remove all debug logs first: + +```bash +# Find frontend debug logs +grep -rn 'console.log("\[WS-DEBUG\]' apps/web/ + +# Find backend debug logs +grep -rn '\[DEBUG\]' apps/backend/ + +# Or use the prefix agreed with the user +grep -rn '\[AREA-DEBUG\]' apps/ +``` + +Verify no debug logs remain in staged files before proceeding with `/commit` or `/pr`. diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index 0a25896ceb..b580beca39 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -727,15 +727,25 @@ func (h *GitHandlers) wsCumulativeDiff(ctx context.Context, msg *ws.Message) (*w return nil, fmt.Errorf("session_id is required") } - agentClient, err := h.getAgentCtlClient(req.SessionID) + // Use GetOrEnsureExecution to recover workspace after backend restarts. + // This is a workspace-oriented operation that doesn't require a running agent process. + execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, req.SessionID) if err != nil { - if isSessionNotReadyError(err) { + if errors.Is(err, lifecycle.ErrSessionWorkspaceNotReady) || isSessionNotReadyError(err) { return ws.NewResponse(msg.ID, msg.Action, map[string]any{ "cumulative_diff": nil, "ready": false, }) } - return nil, err + return nil, fmt.Errorf("failed to get execution for session %s: %w", req.SessionID, err) + } + + agentClient := execution.GetAgentCtlClient() + if agentClient == nil { + return ws.NewResponse(msg.ID, msg.Action, map[string]any{ + "cumulative_diff": nil, + "ready": false, + }) } // Look up base commit SHA from the session metadata diff --git a/apps/backend/internal/agent/handlers/git_handlers_test.go b/apps/backend/internal/agent/handlers/git_handlers_test.go index e4ba4083cb..4dcf2f949c 100644 --- a/apps/backend/internal/agent/handlers/git_handlers_test.go +++ b/apps/backend/internal/agent/handlers/git_handlers_test.go @@ -395,7 +395,10 @@ func TestWsGitCommits_NotReady(t *testing.T) { func TestWsCumulativeDiff_NotReady(t *testing.T) { log := newTestLogger() - lookup := &mockExecutionLookup{} // no executions → "no agent running" error + // Use "no agent running for session" error which matches isSessionNotReadyError + lookup := &mockExecutionLookup{ + ensureErr: fmt.Errorf("no agent running for session session-1"), + } h := NewGitHandlers(lookup, nil, log) msg, _ := ws.NewRequest("test-1", ws.ActionSessionCumulativeDiff, CumulativeDiffRequest{SessionID: "session-1"}) @@ -420,6 +423,82 @@ func TestWsCumulativeDiff_NotReady(t *testing.T) { } } +func TestWsCumulativeDiff_WorkspaceNotReady(t *testing.T) { + log := newTestLogger() + lookup := &mockExecutionLookup{ + ensureErr: lifecycle.ErrSessionWorkspaceNotReady, + } + h := NewGitHandlers(lookup, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionSessionCumulativeDiff, CumulativeDiffRequest{SessionID: "session-1"}) + + resp, err := h.wsCumulativeDiff(context.Background(), msg) + if err != nil { + t.Fatalf("expected graceful response for workspace not ready, got error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + + var payload map[string]any + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatalf("failed to parse payload: %v", err) + } + if ready, ok := payload["ready"].(bool); !ok || ready { + t.Errorf("expected ready=false, got %v", payload["ready"]) + } + if payload["cumulative_diff"] != nil { + t.Errorf("expected cumulative_diff=nil, got %v", payload["cumulative_diff"]) + } +} + +func TestWsCumulativeDiff_NilAgentClient(t *testing.T) { + log := newTestLogger() + // Execution exists but has no agentctl client yet + lookup := &mockExecutionLookup{ + executions: map[string]*lifecycle.AgentExecution{ + "session-1": {ID: "exec-1", SessionID: "session-1"}, + }, + } + h := NewGitHandlers(lookup, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionSessionCumulativeDiff, CumulativeDiffRequest{SessionID: "session-1"}) + + resp, err := h.wsCumulativeDiff(context.Background(), msg) + if err != nil { + t.Fatalf("expected graceful response for nil agent client, got error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + + var payload map[string]any + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatalf("failed to parse payload: %v", err) + } + if ready, ok := payload["ready"].(bool); !ok || ready { + t.Errorf("expected ready=false, got %v", payload["ready"]) + } + if payload["cumulative_diff"] != nil { + t.Errorf("expected cumulative_diff=nil, got %v", payload["cumulative_diff"]) + } +} + +func TestWsCumulativeDiff_UnexpectedError(t *testing.T) { + log := newTestLogger() + lookup := &mockExecutionLookup{ + ensureErr: errors.New("database connection failed"), + } + h := NewGitHandlers(lookup, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionSessionCumulativeDiff, CumulativeDiffRequest{SessionID: "session-1"}) + + _, err := h.wsCumulativeDiff(context.Background(), msg) + if err == nil { + t.Fatal("expected error for unexpected failure") + } +} + func TestIsSessionNotReadyError(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index fb96b11277..62de6baf61 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -586,6 +586,12 @@ func (c *Client) BaseURL() string { return c.baseURL } +// AuthToken returns the Bearer token used for authenticating requests to agentctl. +// Returns empty string if no token is configured. +func (c *Client) AuthToken() string { + return c.authToken +} + // Host returns the host portion (without port) of the agentctl client URL. func (c *Client) Host() string { parsed, err := url.Parse(c.baseURL) diff --git a/apps/backend/internal/gateway/websocket/port_proxy.go b/apps/backend/internal/gateway/websocket/port_proxy.go index 2f0619ac89..6770fe9cc9 100644 --- a/apps/backend/internal/gateway/websocket/port_proxy.go +++ b/apps/backend/internal/gateway/websocket/port_proxy.go @@ -122,7 +122,8 @@ func (h *PortProxyHandler) resolveProxy(c *gin.Context, sessionID string, port i return nil, err } - proxy := h.createProxy(cacheKey, target) + authToken := agentctlClient.AuthToken() + proxy := h.createProxy(cacheKey, target, authToken) h.proxies[cacheKey] = &portProxyEntry{proxy: proxy, target: baseURL} h.logger.Info("created port proxy", @@ -133,12 +134,16 @@ func (h *PortProxyHandler) resolveProxy(c *gin.Context, sessionID string, port i return proxy, nil } -func (h *PortProxyHandler) createProxy(cacheKey string, target *url.URL) *httputil.ReverseProxy { +func (h *PortProxyHandler) createProxy(cacheKey string, target *url.URL, authToken string) *httputil.ReverseProxy { proxy := &httputil.ReverseProxy{} proxy.Rewrite = func(r *httputil.ProxyRequest) { r.SetURL(target) r.Out.URL.Path = r.In.URL.Path r.Out.URL.RawPath = "" + // Inject agentctl auth token + if authToken != "" { + r.Out.Header.Set("Authorization", "Bearer "+authToken) + } if r.Out.Header.Get("Upgrade") != "" { r.Out.Header.Set("Connection", "Upgrade") } diff --git a/apps/backend/internal/gateway/websocket/port_tunnel.go b/apps/backend/internal/gateway/websocket/port_tunnel.go index ea021dee79..c120e1731d 100644 --- a/apps/backend/internal/gateway/websocket/port_tunnel.go +++ b/apps/backend/internal/gateway/websocket/port_tunnel.go @@ -73,13 +73,13 @@ func (m *TunnelManager) StartTunnel(sessionID string, port int, tunnelPort int) m.tunnels[cacheKey] = nil m.mu.Unlock() - target, ln, err := m.resolveAndBind(cacheKey, sessionID, tunnelPort) + target, authToken, ln, err := m.resolveAndBind(cacheKey, sessionID, tunnelPort) if err != nil { return 0, err } actualPort := ln.Addr().(*net.TCPAddr).Port - proxy := m.createTunnelProxy(cacheKey, target, port) + proxy := m.createTunnelProxy(cacheKey, target, port, authToken) ctx, cancel := context.WithCancel(context.Background()) srv := &http.Server{Handler: proxy} @@ -108,7 +108,7 @@ func (m *TunnelManager) StartTunnel(sessionID string, port int, tunnelPort int) // resolveAndBind resolves the agentctl target URL for the session and binds a // local TCP listener for the tunnel. On failure it cleans up the placeholder // entry reserved by StartTunnel. -func (m *TunnelManager) resolveAndBind(cacheKey, sessionID string, tunnelPort int) (*url.URL, net.Listener, error) { +func (m *TunnelManager) resolveAndBind(cacheKey, sessionID string, tunnelPort int) (*url.URL, string, net.Listener, error) { cleanup := func() { m.mu.Lock() if m.tunnels[cacheKey] == nil { @@ -120,31 +120,32 @@ func (m *TunnelManager) resolveAndBind(cacheKey, sessionID string, tunnelPort in execution, ok := m.lifecycleMgr.GetExecutionBySessionID(sessionID) if !ok { cleanup() - return nil, nil, fmt.Errorf("session not found or no active execution") + return nil, "", nil, fmt.Errorf("session not found or no active execution") } agentctlClient := execution.GetAgentCtlClient() if agentctlClient == nil { cleanup() - return nil, nil, fmt.Errorf("agentctl client not available") + return nil, "", nil, fmt.Errorf("agentctl client not available") } target, err := url.Parse(agentctlClient.BaseURL()) if err != nil { cleanup() - return nil, nil, fmt.Errorf("failed to parse agentctl URL: %w", err) + return nil, "", nil, fmt.Errorf("failed to parse agentctl URL: %w", err) } + authToken := agentctlClient.AuthToken() ln, err := net.Listen("tcp", fmt.Sprintf(":%d", tunnelPort)) if err != nil { cleanup() if isAddrInUse(err) { - return nil, nil, fmt.Errorf("port %d is already in use, choose a different port", tunnelPort) + return nil, "", nil, fmt.Errorf("port %d is already in use, choose a different port", tunnelPort) } - return nil, nil, fmt.Errorf("failed to bind tunnel port %d: %w", tunnelPort, err) + return nil, "", nil, fmt.Errorf("failed to bind tunnel port %d: %w", tunnelPort, err) } - return target, ln, nil + return target, authToken, ln, nil } // serveTunnel starts the tunnel HTTP server and its shutdown goroutine. @@ -271,7 +272,7 @@ func (m *TunnelManager) removeTunnel(cacheKey string) { delete(m.tunnels, cacheKey) } -func (m *TunnelManager) createTunnelProxy(cacheKey string, target *url.URL, port int) *httputil.ReverseProxy { +func (m *TunnelManager) createTunnelProxy(cacheKey string, target *url.URL, port int, authToken string) *httputil.ReverseProxy { portStr := strconv.Itoa(port) proxy := &httputil.ReverseProxy{} @@ -284,6 +285,10 @@ func (m *TunnelManager) createTunnelProxy(cacheKey string, target *url.URL, port } r.Out.URL.Path = "/api/v1/port-proxy/" + portStr + incoming r.Out.URL.RawPath = "" + // Inject agentctl auth token + if authToken != "" { + r.Out.Header.Set("Authorization", "Bearer "+authToken) + } // Preserve original Host header for CORS/Origin validation. r.Out.Host = r.In.Host if r.Out.Header.Get("Upgrade") != "" { diff --git a/apps/backend/internal/gateway/websocket/vscode_proxy.go b/apps/backend/internal/gateway/websocket/vscode_proxy.go index 4b7082ec08..a37770adb4 100644 --- a/apps/backend/internal/gateway/websocket/vscode_proxy.go +++ b/apps/backend/internal/gateway/websocket/vscode_proxy.go @@ -149,7 +149,8 @@ func (h *VscodeProxyHandler) resolveProxy(c *gin.Context, sessionID string) (*ht return nil, err } - proxy := h.createProxy(sessionID, target) + authToken := agentctlClient.AuthToken() + proxy := h.createProxy(sessionID, target, authToken) h.proxies[sessionID] = &proxyEntry{proxy: proxy, target: baseURL} h.logger.Info("created vscode proxy for session", @@ -161,13 +162,17 @@ func (h *VscodeProxyHandler) resolveProxy(c *gin.Context, sessionID string) (*ht } // createProxy builds a reverse proxy for the given target URL. -func (h *VscodeProxyHandler) createProxy(sessionID string, target *url.URL) *httputil.ReverseProxy { +func (h *VscodeProxyHandler) createProxy(sessionID string, target *url.URL, authToken string) *httputil.ReverseProxy { proxy := httputil.NewSingleHostReverseProxy(target) // Allow WebSocket upgrades by preserving hop-by-hop headers originalDirector := proxy.Director proxy.Director = func(req *http.Request) { originalDirector(req) + // Inject agentctl auth token + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } // Preserve WebSocket headers that SingleHostReverseProxy strips if req.Header.Get("Upgrade") != "" { req.Header.Set("Connection", "Upgrade") diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 037d2d48fc..b238aa3014 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -113,7 +113,8 @@ export function resolvePRPanelTargetGroup( centerGroupId: string, ): string { const sessionPanel = api.getPanel(`session:${sessionId}`); - return sessionPanel?.group?.id ?? centerGroupId; + const resolved = sessionPanel?.group?.id ?? centerGroupId; + return resolved; } /** @@ -141,14 +142,14 @@ export function useAutoPRPanel() { const api = useDockviewStore.getState().api; if (!api) return; - const decision = shouldAutoAddPRPanel({ + const decisionParams = { hasPR, panelExists: !!api.getPanel("pr-detail"), isRestoringLayout: useDockviewStore.getState().isRestoringLayout, isMaximized: useDockviewStore.getState().preMaximizeLayout !== null, wasOffered: wasPRPanelOffered(sessionId), - }); - + }; + const decision = shouldAutoAddPRPanel(decisionParams); if (decision === "remove") { api.getPanel("pr-detail")?.api.close(); return; diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 40d885ff18..b061691ef2 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -27,6 +27,26 @@ import { useArchivedTaskState } from "./task-archived-context"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; +/** + * Stabilize a derived array of primary session IDs so the reference only + * changes when the actual contents change. This prevents the bulk-subscribe + * effect from tearing down and recreating all subscriptions on every kanban + * snapshot update. + */ +function useStablePrimarySessionIds( + allTasks: Array<{ primarySessionId?: string | null }>, +): string[] { + const key = useMemo( + () => + allTasks + .map((t) => t.primarySessionId) + .filter((id): id is string => id != null) + .join("\0"), + [allTasks], + ); + return useMemo(() => (key ? key.split("\0") : []), [key]); +} + /** Find a task across all workflow snapshots */ function findTaskInSnapshots( snapshots: Record, @@ -264,10 +284,7 @@ function useSidebarData(workspaceId: string | null) { // Stable list of primary session IDs for the bulk-subscribe effect. // Derived from kanban tasks (always available) rather than sessionsByTaskId (loaded on-demand). - const primarySessionIds = useMemo( - () => allTasks.map((t) => t.primarySessionId).filter((id): id is string => id != null), - [allTasks], - ); + const primarySessionIds = useStablePrimarySessionIds(allTasks); return { activeTaskId, @@ -512,13 +529,18 @@ function useSidebarActions(store: StoreApi) { function useBulkGitStatusSubscription(primarySessionIds: string[]) { const connectionStatus = useAppStore((state) => state.connection.status); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); useEffect(() => { if (connectionStatus !== "connected" || primarySessionIds.length === 0) return; const client = getWebSocketClient(); if (!client) return; - const unsubscribes = primarySessionIds.map((id) => client.subscribeSession(id)); + // Skip active session — it's already subscribed + focused by the task page hooks + const backgroundIds = activeSessionId + ? primarySessionIds.filter((id) => id !== activeSessionId) + : primarySessionIds; + const unsubscribes = backgroundIds.map((id) => client.subscribeSession(id)); return () => unsubscribes.forEach((u) => u()); - }, [primarySessionIds, connectionStatus]); + }, [primarySessionIds, connectionStatus, activeSessionId]); } function useEffectiveSidebarView() { diff --git a/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts b/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts index dcedcd6581..b49b1a58c6 100644 --- a/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts +++ b/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts @@ -478,5 +478,16 @@ test.describe("PR detail panel", () => { await session.idleInput().waitFor({ state: "visible", timeout: 30_000 }); await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); await session.expectPrPanelAndSessionShareGroup(); + + // Switch BACK to Task A — regression: the fast-path layout switch used to + // remove the old session tab then look for a chat/session panel to anchor + // the new tab. With the old tab gone and the PR panel being non-chat, the + // lookup failed and fell through to a sidebar split, placing the session + // tab in a new group while the PR panel stayed in the old center group. + await session.clickTaskInSidebar("First PR Task"); + await session.waitForLoad(); + await session.idleInput().waitFor({ state: "visible", timeout: 30_000 }); + await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); + await session.expectPrPanelAndSessionShareGroup(); }); }); diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index 4b994e90c0..5528ebf8b7 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -21,7 +21,9 @@ async function fetchAndStoreMessages( store: ReturnType, ): Promise { const client = getWebSocketClient(); - if (!client) return []; + if (!client) { + return []; + } const response = await client.request( "message.list", @@ -29,7 +31,6 @@ async function fetchAndStoreMessages( 10000, ); const fetched = [...(response.messages ?? [])].reverse(); - // Merge: keep WS-delivered messages that aren't in the fetch response. // This prevents a slow fetch (sent before messages existed) from wiping // messages that arrived via real-time notifications while the fetch was @@ -145,6 +146,32 @@ export function useVisibilityBackfill( }, [taskSessionId, store]); } +function useSessionSubscription( + taskSessionId: string | null, + connectionStatus: string, + isSessionStartingOrUnknown: boolean, + store: ReturnType, +) { + useEffect(() => { + if (!taskSessionId || connectionStatus !== "connected") { + return; + } + const client = getWebSocketClient(); + if (!client) { + return; + } + const unsubscribe = client.subscribeSession(taskSessionId); + + // Re-fetch messages after subscribing to close the gap between SSR + // (which may have run before the agent responded) and this subscription. + fetchAndStoreMessages(taskSessionId, store).catch(() => {}); + + return () => { + unsubscribe(); + }; + }, [taskSessionId, connectionStatus, store, isSessionStartingOrUnknown]); +} + export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn { const store = useAppStoreApi(); const messages = useAppStore((state) => @@ -232,23 +259,7 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess // churning on every subsequent RUNNING ↔ WAITING_FOR_INPUT transition. const isSessionStartingOrUnknown = taskSessionState === null || taskSessionState === "STARTING"; - useEffect(() => { - if (!taskSessionId || connectionStatus !== "connected") return; - const client = getWebSocketClient(); - if (!client) return; - const unsubscribe = client.subscribeSession(taskSessionId); - - // Re-fetch messages after subscribing to close the gap between SSR - // (which may have run before the agent responded) and this subscription. - // Without this, fast-responding agents can complete a turn before the - // subscription is active, causing the response to never appear. - fetchAndStoreMessages(taskSessionId, store).catch(() => {}); - - return () => { - unsubscribe(); - }; - }, [taskSessionId, connectionStatus, store, isSessionStartingOrUnknown]); - + useSessionSubscription(taskSessionId, connectionStatus, isSessionStartingOrUnknown, store); useVisibilityBackfill(taskSessionId, store); const terminalFetchRefs = useMemo( diff --git a/apps/web/lib/state/dockview-session-switch.ts b/apps/web/lib/state/dockview-session-switch.ts index 0baa3965a8..ab7b7f7660 100644 --- a/apps/web/lib/state/dockview-session-switch.ts +++ b/apps/web/lib/state/dockview-session-switch.ts @@ -96,6 +96,16 @@ function tryFastSessionSwitch(params: SessionSwitchParams): LayoutGroupIds | nul // would discard the current ones without restoring the target session's. if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) return null; + // Capture the outgoing session tab's group BEFORE removing it so we can + // place the new session tab in the same group. Without this, the lookup + // after removeEphemeralPanels finds no chat/session panels and falls + // through to a sidebar split — putting the new tab in a new group while + // the PR panel stays in the old center group. + const outgoingSessionPanel = api.panels.find( + (p) => p.id.startsWith("session:") || p.api.component === "chat", + ); + const outgoingGroupId = outgoingSessionPanel?.group?.id; + // Fast path: keep the grid structure, clean up ephemeral panels and any // session chat tabs that belong to a different session (cross-task switch). // Session-scoped portals (browser, vscode, etc.) will be re-acquired @@ -106,13 +116,10 @@ function tryFastSessionSwitch(params: SessionSwitchParams): LayoutGroupIds | nul // old tab and creating the new one. useAutoSessionTab will detect the panel // already exists and skip creation. if (!api.getPanel(`session:${newSessionId}`)) { - const chatPanel = api.panels.find( - (p) => p.id.startsWith("session:") || p.api.component === "chat", - ); const sidebarPanel = api.getPanel("sidebar"); let position: import("dockview-react").AddPanelOptions["position"]; - if (chatPanel) { - position = { referenceGroup: chatPanel.group.id }; + if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) { + position = { referenceGroup: outgoingGroupId }; } else if (sidebarPanel) { position = { direction: "right" as const, referencePanel: "sidebar" }; } diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts index f11bc8d043..2af5f88ae5 100644 --- a/apps/web/lib/ws/client.ts +++ b/apps/web/lib/ws/client.ts @@ -152,7 +152,6 @@ export class WebSocketClient { const currentCount = this.sessionSubscriptions.get(sessionId) ?? 0; const nextCount = currentCount + 1; this.sessionSubscriptions.set(sessionId, nextCount); - if (this.status === "open" && nextCount === 1) { this.send({ id: generateUUID(), @@ -231,7 +230,9 @@ export class WebSocketClient { unsubscribeSession(sessionId: string) { const currentCount = this.sessionSubscriptions.get(sessionId); - if (!currentCount) return; + if (!currentCount) { + return; + } const nextCount = currentCount - 1; if (nextCount <= 0) {