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;