From ac49740a50b8d25ac0a67d40a14bad4527f1e16b Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 15 Oct 2026 18:35:39 +0900 Subject: [PATCH] fix: simplify Ticket sidebar and surface repository targets --- .../web-ux/browser-tests/ticket_shell_test.ts | 58 ++++- .../src/lib/workspace/styles/tickets.css | 18 +- .../workspace/tickets/TicketHandling.svelte | 1 - .../lib/workspace/tickets/TicketTags.svelte | 1 - .../tickets/[ticketId]/PageContent.svelte | 221 +++++++++--------- .../test/ticket-detail-route-reuse.test.ts | 6 +- 6 files changed, 185 insertions(+), 120 deletions(-) diff --git a/tools/web-ux/browser-tests/ticket_shell_test.ts b/tools/web-ux/browser-tests/ticket_shell_test.ts index 9f8b9f00e..641f8992e 100644 --- a/tools/web-ux/browser-tests/ticket_shell_test.ts +++ b/tools/web-ux/browser-tests/ticket_shell_test.ts @@ -408,14 +408,20 @@ async function assertThreadLayout(page: Page) { assertEquals(await page.locator(".ticket-delivery").count(), 0); assertEquals(await page.getByText("Delivery and execution", { exact: true }).count(), 0); assertEquals(await rail.locator(".ticket-thread-event[data-initial=true], .ticket-thread-event, .ticket-thread-composer").count(), 0); - for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) { + for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resource-editor"]) { const details = rail.locator(selector); assertEquals(await details.evaluate((element) => element.tagName), "DETAILS"); assertEquals(await details.getAttribute("open"), null); assert(await details.locator(":scope > summary").isVisible()); } assertEquals(await rail.locator(".ticket-worker-card > summary").textContent(), "Role assignments and manual Workers"); - assert(/^Repository resources \(\d+\)$/.test((await rail.locator(".ticket-resources > summary").textContent())!)); + assert(await rail.getByRole("heading", { name: "Repository resources", exact: true }).isVisible()); + assert(await rail.locator(".ticket-resource-summary").isVisible()); + assertEquals(await rail.locator(":scope > section").first().getAttribute("aria-label"), "Repository resources"); + assert(await rail.locator(".ticket-resources").evaluate((element) => + element.getBoundingClientRect().bottom <= element.parentElement!.querySelector(".ticket-handling")!.getBoundingClientRect().top + )); + assertEquals(await rail.locator(".ticket-resource-editor > summary").textContent(), "Edit resources"); const evidence = rail.locator("details").filter({ has: page.locator("summary", { hasText: /^MR requirement evidence$/ }) }); if (await evidence.count()) assertEquals(await evidence.getAttribute("open"), null); } @@ -1090,6 +1096,43 @@ Deno.test("canonical thread renders long initial post and chronological multilin }); }); +Deno.test("Repository summary stays visible while edits remain explicit and refresh after saving", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + const wire = await detail(base, "T-1"); + wire.targets = [{ repository_key: "main", ref_selector: null, access: "read_only" }]; + const writes: unknown[] = []; + await page.route("**/api/w/tickets/tickets/*", async (route) => { + const reference = decodeURIComponent(new URL(route.request().url()).pathname.split("/").at(-1)!); + if (reference !== "T-1" && reference !== wire.id) return await route.continue(); + if (route.request().method() === "PATCH") { + const body = route.request().postDataJSON(); + writes.push(body); + wire.targets = body.target.action === "clear" ? [] : body.target.targets; + } + await route.fulfill({ status: 200, json: wire }); + }); + await page.goto(`${base}/w/tickets/tickets/T-1`); + const resources = page.getByRole("region", { name: "Repository resources", exact: true }); + await resources.getByText("Ref not specified", { exact: true }).waitFor(); + const summary = resources.locator(".ticket-resource-editor > summary"); + await summary.focus(); + await summary.press("Enter"); + await resources.getByLabel("Ref selector", { exact: true }).fill("feature/sidebar"); + await resources.getByRole("combobox", { name: /^Access/ }).selectOption("read_write"); + assertEquals(writes, []); + assertEquals(await resources.locator(".ticket-resource-summary code").textContent(), "Ref not specified"); + await resources.getByRole("button", { name: "Save targets", exact: true }).click(); + await resources.locator(".ticket-resource-summary code").filter({ hasText: "feature/sidebar" }).waitFor(); + assertEquals(writes, [{ target: { action: "set", targets: [{ repository_key: "main", ref_selector: "feature/sidebar", access: "read_write" }] } }]); + assertEquals(await resources.locator(".ticket-resource-access").textContent(), "Read/write"); + await resources.getByRole("button", { name: "Remove target", exact: true }).click(); + await resources.getByRole("button", { name: "Save targets", exact: true }).click(); + await resources.locator(".ticket-resource-summary").getByText("No repository targets.", { exact: true }).waitFor(); + assertEquals(writes[1], { target: { action: "clear" } }); + }); +}); + Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels and keyboard navigation", async () => { await withFixture(async (browser, base) => { const output = artifactPath("metadata"); @@ -1102,6 +1145,10 @@ Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels an const body = await response.json(); if (body.resource_key === "T-1" && body.relations) { body.tags[0].name = "Classification-with-a-long-name-".repeat(4); + body.targets = [ + { repository_key: "Repository-with-a-long-name-".repeat(4), ref_selector: "feature/long-selector-".repeat(6), access: "read_write" }, + { repository_key: "docs", ref_selector: null, access: "read_only" }, + ]; body.relations.outgoing = [{ at: "2026-01-01T00:00:00Z", author: "fixture-owner", kind: "related", target: "internal-T-2", target_resource_key: "T-2", ticket_id: body.id, note: "Related investigation with a long note. ".repeat(5) }]; } await route.fulfill({ response, json: body }); @@ -1111,6 +1158,13 @@ Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels an const link = rail.getByRole("link", { name: /T-2/ }); await link.waitFor(); assertEquals(await link.getAttribute("href"), "/w/tickets/tickets/T-2"); + const resources = rail.getByRole("region", { name: "Repository resources", exact: true }); + assertEquals(await resources.locator(".ticket-resource-summary strong").allTextContents(), ["Repository-with-a-long-name-".repeat(4), "docs"]); + assertEquals(await resources.locator(".ticket-resource-summary code").allTextContents(), ["feature/long-selector-".repeat(6), "Ref not specified"]); + assertEquals(await resources.locator(".ticket-resource-access").allTextContents(), ["Read/write", "Read only"]); + assert(await resources.locator(".ticket-resource-summary").evaluate((element) => element.scrollWidth <= element.clientWidth)); + assertEquals(await resources.locator(".ticket-resource-editor").getAttribute("open"), null); + await resources.screenshot({ path: `${output}/resources-${colorScheme}-${width}.png` }); assertEquals(await page.locator(".ticket-detail-main .ticket-tags, .ticket-detail-main .ticket-relations").count(), 0); assertEquals(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false); assert(await rail.locator(".ticket-tag-chip, .ticket-relations-list a").evaluateAll((elements) => elements.every((element) => { diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index 64b8985b4..9d06af3bf 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -344,14 +344,24 @@ } .ticket-post-attribution { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); min-width: 0; overflow-wrap: anywhere; } .ticket-post-edited { color: var(--text-muted); font-size: var(--font-size-compact); } - .ticket-metadata dl { display: grid; gap: var(--space-1); margin: 0 0 var(--space-3); } - .ticket-metadata dt { color: var(--text-muted); font-size: var(--font-size-compact); } - .ticket-metadata dd { margin: 0 0 var(--space-2); overflow-wrap: anywhere; } + .ticket-resource-summary { display: grid; gap: var(--space-2); list-style: none; padding: 0; margin: 0 0 var(--space-2); } + .ticket-resource-summary li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-1) var(--space-2); align-items: baseline; } + .ticket-resource-summary code { grid-column: 1 / -1; overflow-wrap: anywhere; color: var(--text-muted); font-size: inherit; } + .ticket-resource-access { color: var(--text-muted); white-space: nowrap; } + .ticket-metadata dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-1) var(--space-3); margin: 0 0 var(--space-2); } + .ticket-metadata dt { color: var(--text-muted); } + .ticket-metadata dd { margin: 0; overflow-wrap: anywhere; } + .ticket-detail-page .ticket-control-rail { gap: var(--space-2); font-size: var(--font-size-compact); } + .ticket-control-rail :is(.ticket-section-heading, .ticket-control-card > header) { margin-bottom: var(--space-2); } + .ticket-control-rail :is(.ticket-section-heading h2, .ticket-control-card h2) { font-size: var(--font-size-compact); } + .ticket-control-rail :is(.workspace-primary-button, .workspace-secondary-button, .workspace-danger-button) { min-height: 2rem; padding: var(--space-1) var(--space-2); } + .ticket-control-rail .ticket-relations-list a { grid-template-columns: auto minmax(0, 1fr); gap: var(--space-1) var(--space-2); padding-block: var(--space-1); } + .ticket-relations-list small { grid-column: 1 / -1; } .ticket-metadata .ticket-editor { margin-top: var(--space-3); } .ticket-detail-page .ticket-control-rail summary { cursor: pointer; color: var(--text-strong); - font-size: var(--font-size-body); + font-size: var(--font-size-compact); font-weight: 600; } .ticket-control-rail details[open] > summary { margin-bottom: var(--space-3); } diff --git a/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte b/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte index 9e5356378..763b260db 100644 --- a/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte +++ b/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte @@ -150,7 +150,6 @@ {#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done"}
-

Continues assignment {current.assignment_id}; not a new delegation. A stale assignment is rejected.

{/if} diff --git a/web/workspace/src/lib/workspace/tickets/TicketTags.svelte b/web/workspace/src/lib/workspace/tickets/TicketTags.svelte index 7734f87db..a70052796 100644 --- a/web/workspace/src/lib/workspace/tickets/TicketTags.svelte +++ b/web/workspace/src/lib/workspace/tickets/TicketTags.svelte @@ -60,7 +60,6 @@

Classification tags

-

Classification only. Tag changes do not close or reopen this case.

{#each ticket.tags as tag (tag.id)}{tag.name}{:else}No tags{/each}
{#if loading}

Loading tag catalog…

{/if} {#if error}{/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte index c913dfd9d..0be8b74da 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte @@ -524,23 +524,60 @@
+ {#if mergeRequests.length > 0} + +
+ MR requirement evidence +

Current requirements: {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.

+

Current requirement approval: {currentRequirementApprovalStatus(ticket.evidence)}.

+ {#if ticket.evidence.missing.length > 0} +
    + {#each ticket.evidence.missing as missing}
  • {missing}
  • {/each} +
+ {/if} +

This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.

+ {#if ticket.state === "closed"} +

This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.

+ {/if} +
+ {/if} + +
+

Merge Requests

+ {#if mergeRequests.length > 0} + {#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)} +
+

{mergeRequest.repository_key}: {mergeRequest.state}

+

+ From {mergeRequest.selector_from ?? "requires repair"} + to {mergeRequest.selector_to} +

+

{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}: {summarySourceReviewStatus(mergeRequest)}

+ {#if mergeRequest.integration_evidence_error !== null} +

Integration evidence error: {mergeRequest.integration_evidence_error}

+ {/if} +

Target integration: {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.

+ Open Merge Request +
+ {/each} + {:else} +

No Merge Requests linked.

+ {/if} +
+ {#key JSON.stringify([data.workspaceId, ticket.id])} @@ -609,7 +690,7 @@
Role assignments and manual Workers -

Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.

+

Assignments are retained independently of Worker state.

{#if ticket.assignments.length > 0}
    {#each ticket.assignments as assignment} @@ -633,11 +714,10 @@ {#if implementationStartEligible} Start a Ticket Worker
    Assign an existing Worker -

    Explicit assignment is separate from state. Backend validates the Worker and resource binding; a retained assignment is not execution authority.

    -

    Select only resources needed for this request. No selection binds no Workdirs; unrelated existing attachments are not inherited. Use authorized connection IDs for explicit rebind.

    +

    Only selected Workdirs are bound. Rebinding requires authorized connection IDs.

    {#each manualBindings as binding, index}
    Resource binding {index + 1} @@ -682,95 +762,22 @@
    -
    - Repository resources ({ticket.targets.length}) -

    Targets describe permitted resources, not connected attachments. Editing targets or the body does not expand live permissions. Changed resources require explicit reconnection; stale or unavailable connections are rejected by Backend.

    - {#if data.repositories.error}

    Repository catalog unavailable: {data.repositories.error}

    {/if} - -
    - {#each targetDrafts as target, index} - {@const selectedRepository = repositoryFor(target.repository_key)} -
    - Target {index + 1} - - - - -
    - {:else} -

    No repository targets.

    - {/each} -
    - - - -
    - - {#if mergeRequests.length > 0} - -
    - MR requirement evidence -

    Current requirements: {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.

    -

    Current requirement approval: {currentRequirementApprovalStatus(ticket.evidence)}.

    - {#if ticket.evidence.missing.length > 0} -
      - {#each ticket.evidence.missing as missing}
    • {missing}
    • {/each} -
    - {/if} -

    This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.

    - {#if ticket.state === "closed"} -

    This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.

    - {/if} -
    - {/if} - -
    -

    Merge Requests

    -

    Ticket closure is not MR approval or integration.

    - {#if mergeRequests.length > 0} - {#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)} -
    -

    {mergeRequest.repository_key}: {mergeRequest.state}

    -

    - From {mergeRequest.selector_from ?? "requires repair"} - to {mergeRequest.selector_to} -

    -

    {mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}: {summarySourceReviewStatus(mergeRequest)}

    - {#if mergeRequest.integration_evidence_error !== null} -

    Integration evidence error: {mergeRequest.integration_evidence_error}

    - {/if} -

    Target integration: {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.

    - {#if mergeRequest.state === "merged"} -

    The merged source and integration approval are persisted evidence, not a live selector observation.

    - {:else} -

    Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.

    - {/if} - Open Merge Request -
    - {/each} - {:else} -

    No Merge Requests linked. A Ticket can be closed with a comment or document result.

    + diff --git a/web/workspace/test/ticket-detail-route-reuse.test.ts b/web/workspace/test/ticket-detail-route-reuse.test.ts index bef0c69c9..9f33f65f0 100644 --- a/web/workspace/test/ticket-detail-route-reuse.test.ts +++ b/web/workspace/test/ticket-detail-route-reuse.test.ts @@ -63,11 +63,7 @@ Deno.test("ticket detail fences stale mutation responses", () => { Deno.test("ticket responsibility display does not claim Worker liveness or work authority", () => { assertStringIncludes(pageSource, "Role assignments and manual Workers"); - assertStringIncludes(pageSource, "after work ends and Worker removal"); - assertStringIncludes( - pageSource, - "does not indicate a running Worker or active work authority", - ); + assertStringIncludes(pageSource, "Assignments are retained independently of Worker state."); assertStringIncludes(pageSource, "assignment.principal.runtime_id"); assertStringIncludes(pageSource, "assignment.principal.worker_id"); assert(!pageSource.includes("No active role assignment."));