From 4dbb8a7811a90f2f3707b0b2c5427fc3a2caa8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 25 Aug 2026 22:57:20 +0000 Subject: [PATCH] fix: align sidebar PR badges and expose run scope (#3020) --- .../office/costs/modelsdev/client_test.go | 29 ++++----- apps/backend/internal/office/dashboard/dto.go | 4 ++ .../internal/office/dashboard/run_detail.go | 21 ++++--- .../office/dashboard/run_detail_test.go | 13 ++++ apps/web/components/task/task-item.tsx | 2 +- .../e2e/tests/gitlab/mr-sidebar-badge.spec.ts | 21 +++++++ .../task/mobile-sidebar-task-actions.spec.ts | 61 +++++++++++++++++++ apps/web/lib/api/domains/office-runs-api.ts | 2 + 8 files changed, 125 insertions(+), 28 deletions(-) diff --git a/apps/backend/internal/office/costs/modelsdev/client_test.go b/apps/backend/internal/office/costs/modelsdev/client_test.go index ba7efbf6e4..973875f0cf 100644 --- a/apps/backend/internal/office/costs/modelsdev/client_test.go +++ b/apps/backend/internal/office/costs/modelsdev/client_test.go @@ -421,31 +421,26 @@ func TestClient_FirstBootMissesGracefully(t *testing.T) { dir := t.TempDir() cachePath := filepath.Join(dir, "models-dev.json") - // A cold-boot lookup schedules a background refresh against the - // configured URL. Block that refresh at the server so it can't - // populate the cache before the lookup reads it — otherwise the - // "miss" we're asserting races a fast background fetch and flakes - // into a hit. The request exits when the lookup context is canceled, - // before TempDir cleanup can race a cache write. - ctx, cancel := context.WithCancel(context.Background()) - srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - <-r.Context().Done() - })) - t.Cleanup(func() { - cancel() - srv.Close() - }) + // A cold-boot lookup schedules a detached background refresh. Hold that + // request at the gate so it cannot populate the cache before the lookup + // reads it, then release and join the refresh before TempDir cleanup. + gate := newRequestGate(t, sampleDataset) c := modelsdev.New(modelsdev.Config{ CachePath: cachePath, - URL: srv.URL, + URL: gate.server.URL, TTL: time.Hour, - HTTPClient: srv.Client(), + HTTPClient: gate.server.Client(), }, logger.Default()) // No Refresh — simulating cold boot before any HTTP fetch. - if _, ok := c.LookupForModel(ctx, "claude-opus-4-7"); ok { + if _, ok := c.LookupForModel(context.Background(), "claude-opus-4-7"); ok { t.Error("expected miss on cold-boot lookup") } + gate.waitForFirstRequest(t) + gate.releaseAll() + if err := c.Refresh(context.Background()); err != nil { + t.Fatalf("join background refresh: %v", err) + } } type requestGate struct { diff --git a/apps/backend/internal/office/dashboard/dto.go b/apps/backend/internal/office/dashboard/dto.go index b0aefe1f4e..e530ef3bc8 100644 --- a/apps/backend/internal/office/dashboard/dto.go +++ b/apps/backend/internal/office/dashboard/dto.go @@ -306,6 +306,10 @@ type RunDetailResponse struct { // context the run dispatcher built at claim time. Surfaced for // debugging. ContextSnapshot string `json:"context_snapshot,omitempty"` + // ContinuationScope is the persisted summary scope selected when + // the run was created. It lets run inspection show which + // continuation-summary chain the run reads and updates. + ContinuationScope string `json:"continuation_scope,omitempty"` // OutputSummary mirrors runs.output_summary — the free-form // agent output captured at run finish. Kept alongside ResultJSON // because legacy adapters populate this and not result_json. diff --git a/apps/backend/internal/office/dashboard/run_detail.go b/apps/backend/internal/office/dashboard/run_detail.go index 6e99004679..10e97b704b 100644 --- a/apps/backend/internal/office/dashboard/run_detail.go +++ b/apps/backend/internal/office/dashboard/run_detail.go @@ -276,16 +276,17 @@ func GetRunDetail( CachedTokens: costs.CachedTokens, CostSubcents: costs.CostSubcents, }, - Session: sessionDTO, - Invocation: invocation, - Runtime: runtimeDTO, - TasksTouched: taskIDs, - Events: eventDTOs, - AssembledPrompt: run.AssembledPrompt, - SummaryInjected: run.SummaryInjected, - ResultJSON: run.ResultJSON, - ContextSnapshot: run.ContextSnapshot, - OutputSummary: run.OutputSummary, + Session: sessionDTO, + Invocation: invocation, + Runtime: runtimeDTO, + TasksTouched: taskIDs, + Events: eventDTOs, + AssembledPrompt: run.AssembledPrompt, + SummaryInjected: run.SummaryInjected, + ResultJSON: run.ResultJSON, + ContextSnapshot: run.ContextSnapshot, + ContinuationScope: run.ContinuationScope, + OutputSummary: run.OutputSummary, } if run.CancelReason != nil && *run.CancelReason != "" { v := *run.CancelReason diff --git a/apps/backend/internal/office/dashboard/run_detail_test.go b/apps/backend/internal/office/dashboard/run_detail_test.go index c465017b57..459504241e 100644 --- a/apps/backend/internal/office/dashboard/run_detail_test.go +++ b/apps/backend/internal/office/dashboard/run_detail_test.go @@ -2,6 +2,7 @@ package dashboard_test import ( "context" + "encoding/json" "testing" "time" @@ -395,4 +396,16 @@ func TestGetRunDetail_PromptArtifactsAndSnapshots(t *testing.T) { if resp.ContextSnapshot == "" { t.Errorf("expected context_snapshot to round-trip from default '{}', got empty string") } + + raw, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal run detail response: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode run detail response: %v", err) + } + if got := payload["continuation_scope"]; got != "agent:agent-1" { + t.Errorf("continuation_scope = %v, want %q", got, "agent:agent-1") + } } diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 86a77989fc..e9d091c581 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -328,7 +328,7 @@ function TaskStateIcon({ } function TaskItemTitle({ title }: { title: string }) { - return {title}; + return {title}; } function TaskItemContent({ diff --git a/apps/web/e2e/tests/gitlab/mr-sidebar-badge.spec.ts b/apps/web/e2e/tests/gitlab/mr-sidebar-badge.spec.ts index 4089052f06..bca8951678 100644 --- a/apps/web/e2e/tests/gitlab/mr-sidebar-badge.spec.ts +++ b/apps/web/e2e/tests/gitlab/mr-sidebar-badge.spec.ts @@ -181,5 +181,26 @@ test.describe("GitLab MR badge on the sidebar", () => { : "mr-then-pr"; }); expect(order).toBe("pr-then-mr"); + + const spacing = await row.evaluate((el, titleText) => { + const title = [...el.querySelectorAll("span")].find( + (candidate) => + candidate.classList.contains("whitespace-nowrap") && candidate.textContent === titleText, + ); + const pr = el.querySelector('[data-testid^="pr-task-icon-"]'); + if (!title || !pr) return { found: false, gap: -1, titleTop: -1, prTop: -1 }; + const titleBox = title.getBoundingClientRect(); + const prBox = pr.getBoundingClientRect(); + return { + found: true, + gap: prBox.left - titleBox.right, + titleTop: titleBox.top, + prTop: prBox.top, + }; + }, "Sidebar PR and MR badge task"); + expect(spacing.found).toBe(true); + expect(Math.abs(spacing.titleTop - spacing.prTop)).toBeLessThan(4); + expect(spacing.gap).toBeGreaterThanOrEqual(0); + expect(spacing.gap).toBeLessThan(32); }); }); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts index 0b726306b8..ebcc221790 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { test, expect } from "../../fixtures/test-base"; import { activeSessionId, seedSecondaryClarificationTask } from "../../helpers/clarification"; import { makeGitEnv } from "../../helpers/git-helper"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; import { SessionPage } from "../../pages/session-page"; test.describe("Mobile sidebar task actions", () => { @@ -51,6 +52,66 @@ test.describe("Mobile sidebar task actions", () => { .toBe(true); }); + test("keeps a linked PR badge beside its title in the phone drawer", async ({ + testPage, + apiClient, + seedData, + }) => { + const title = "Mobile sidebar PR badge spacing"; + const task = await apiClient.createTask(seedData.workspaceId, title, { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + owner: "testorg", + repo: "testrepo", + pr_number: 902, + pr_url: "https://github.com/testorg/testrepo/pull/902", + pr_title: "Mobile sidebar spacing", + head_branch: "feature/mobile-sidebar-spacing", + base_branch: "main", + author_login: "e2e", + state: "open", + checks_state: "success", + mergeable_state: "clean", + }); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.mobileSessionMenu.tap(); + + const drawer = testPage.getByRole("dialog", { name: "Tasks" }); + const row = drawer.getByTestId("sidebar-task-item").filter({ hasText: title }); + await expect(row).toBeVisible(); + const prIcon = row.getByTestId(`pr-task-icon-${task.id}`); + await expect(prIcon).toBeVisible({ timeout: 15_000 }); + + const spacing = await row.evaluate((el, titleText) => { + const titleElement = [...el.querySelectorAll("span")].find( + (candidate) => + candidate.classList.contains("whitespace-nowrap") && candidate.textContent === titleText, + ); + const pr = el.querySelector('[data-testid^="pr-task-icon-"]'); + if (!titleElement || !pr) return { found: false, gap: -1, titleTop: -1, prTop: -1 }; + const titleBox = titleElement.getBoundingClientRect(); + const prBox = pr.getBoundingClientRect(); + return { + found: true, + gap: prBox.left - titleBox.right, + titleTop: titleBox.top, + prTop: prBox.top, + }; + }, title); + expect(spacing.found).toBe(true); + expect(Math.abs(spacing.titleTop - spacing.prTop)).toBeLessThan(4); + expect(spacing.gap).toBeGreaterThanOrEqual(0); + expect(spacing.gap).toBeLessThan(32); + await assertNoDocumentHorizontalOverflow(testPage, "mobile sidebar PR badge spacing"); + }); + test("switches to the selected task and its chat from the phone task drawer", async ({ testPage, apiClient, diff --git a/apps/web/lib/api/domains/office-runs-api.ts b/apps/web/lib/api/domains/office-runs-api.ts index 568ba0627d..d2a43620c2 100644 --- a/apps/web/lib/api/domains/office-runs-api.ts +++ b/apps/web/lib/api/domains/office-runs-api.ts @@ -111,6 +111,8 @@ export type RunDetail = { summary_injected?: string; result_json?: string; context_snapshot?: string; + // Persisted when the run is created. Identifies the continuation-summary chain. + continuation_scope?: string; output_summary?: string; // Routing snapshot — omitted for legacy concrete-profile runs. routing?: RunRouting;