chore: retain committed Ticket sidebar changes during notification integration

This commit is contained in:
2026-10-15 18:51:14 +09:00
6 changed files with 185 additions and 120 deletions
@@ -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) => {
@@ -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); }
@@ -150,7 +150,6 @@
{#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done"}
<form class="ticket-control-form ticket-answer" onsubmit={submitAnswer}>
<label>Answer / continue this handling<textarea bind:value={answer} rows="3" required disabled={busy || pending !== null}></textarea></label>
<p>Continues assignment <code>{current.assignment_id}</code>; not a new delegation. A stale assignment is rejected.</p>
<button class="workspace-secondary-button" disabled={busy || pending !== null || !answer.trim()}>Send answer to this assignment</button>
</form>
{/if}
@@ -60,7 +60,6 @@
</script>
<section class="ticket-detail-section ticket-tags">
<div class="ticket-section-heading"><h2>Classification tags</h2></div>
<p class="workspace-empty-copy">Classification only. Tag changes do not close or reopen this case.</p>
<div class="ticket-tag-chips">{#each ticket.tags as tag (tag.id)}<span class="ticket-tag-chip">{tag.name}</span>{:else}<span>No tags</span>{/each}</div>
{#if loading}<p role="status">Loading tag catalog…</p>{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
@@ -524,23 +524,60 @@
<div class="ticket-column-divider"><BevelLine direction="y" length="100%" decorative /></div>
<aside class="ticket-control-rail" aria-label="Ticket details">
<div class="ticket-mobile-sidebar-divider"><BevelLine decorative /></div>
<section class="ticket-control-card ticket-metadata" aria-label="Ticket metadata">
<dl>
<dt>Created by</dt><dd>{ticket.created_by ?? "Unknown author"}</dd>
<dt>Created</dt><dd><time datetime={ticket.created_at ?? undefined}>{prettyDate(ticket.created_at)}</time></dd>
<dt>Updated</dt><dd><time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time></dd>
</dl>
<button class="workspace-secondary-button" type="button" onclick={() => { editTitle = ticket.title; editing = !editing; }}>
{editing ? "Cancel title edit" : "Edit title"}
</button>
{#if editing}
<form class="ticket-editor" aria-label="Edit ticket title" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required disabled={busy !== null} /></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save title"}
<section class="ticket-control-card ticket-resources" aria-label="Repository resources">
<div class="ticket-section-heading"><h2>Repository resources</h2><span>{ticket.targets.length}</span></div>
<ul class="ticket-resource-summary">
{#each ticket.targets as target}
<li>
<strong>{target.repository_key}</strong>
<span class="ticket-resource-access">{target.access === "read_write" ? "Read/write" : "Read only"}</span>
<code>{target.ref_selector ?? "Ref not specified"}</code>
</li>
{:else}
<li class="workspace-empty-copy">No repository targets.</li>
{/each}
</ul>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<details class="ticket-resource-editor">
<summary>Edit resources</summary>
<p class="workspace-empty-copy">Target changes do not reconnect Workers or change live permissions.</p>
<form class="ticket-control-form" onsubmit={saveTarget}>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
{@const selectedRepository = repositoryFor(target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={busy !== null} required>
<option value="">Choose repository</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} disabled={busy !== null} /></label>
<label>Access
<select bind:value={target.access} disabled={busy !== null}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
</fieldset>
{:else}
<p class="workspace-empty-copy">No repository targets.</p>
{/each}
</div>
<button
class="workspace-secondary-button"
type="button"
onclick={() => addTarget()}
>Add target</button>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !targetCandidateValid}>
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
{/if}
</details>
</section>
<BevelLine decorative />
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
@@ -602,6 +639,50 @@
</div>
</section>
{#if mergeRequests.length > 0}
<BevelLine decorative />
<details class="ticket-control-card">
<summary>MR requirement evidence</summary>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
<ul>
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
</ul>
{/if}
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</details>
{/if}
<BevelLine decorative />
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
{#if mergeRequests.length > 0}
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
<article class="ticket-control-card">
<p><strong>{mergeRequest.repository_key}: {mergeRequest.state}</strong></p>
<p>
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
to <code>{mergeRequest.selector_to}</code>
</p>
<p><strong>{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}:</strong> {summarySourceReviewStatus(mergeRequest)}</p>
{#if mergeRequest.integration_evidence_error !== null}
<p class="workspace-callout is-error"><strong>Integration evidence error:</strong> {mergeRequest.integration_evidence_error}</p>
{/if}
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
<a
class="workspace-secondary-button"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>Open Merge Request</a>
</article>
{/each}
{:else}
<p class="workspace-empty-copy">No Merge Requests linked.</p>
{/if}
</section>
<BevelLine decorative />
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
@@ -609,7 +690,7 @@
<BevelLine decorative />
<details class="ticket-control-card ticket-worker-card">
<summary>Role assignments and manual Workers</summary>
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
<p class="workspace-empty-copy">Assignments are retained independently of Worker state.</p>
{#if ticket.assignments.length > 0}
<ul class="ticket-assignment-list">
{#each ticket.assignments as assignment}
@@ -633,11 +714,10 @@
{#if implementationStartEligible}
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket)}>Start a Ticket Worker</a>
<details><summary>Assign an existing Worker</summary>
<p class="workspace-empty-copy">Explicit assignment is separate from state. Backend validates the Worker and resource binding; a retained assignment is not execution authority.</p>
<form class="ticket-control-form" onsubmit={assignExistingWorker}>
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
<p class="workspace-empty-copy">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.</p>
<p class="workspace-empty-copy">Only selected Workdirs are bound. Rebinding requires authorized connection IDs.</p>
{#each manualBindings as binding, index}
<fieldset class="ticket-target-row"><legend>Resource binding {index + 1}</legend>
<label>Alias<input bind:value={binding.alias} required /></label>
@@ -682,95 +762,22 @@
</details>
<BevelLine decorative />
<details class="ticket-control-card ticket-resources">
<summary>Repository resources ({ticket.targets.length})</summary>
<p class="workspace-empty-copy">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.</p>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<form class="ticket-control-form" onsubmit={saveTarget}>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
{@const selectedRepository = repositoryFor(target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={busy !== null} required>
<option value="">Choose repository</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} disabled={busy !== null} /></label>
<label>Access
<select bind:value={target.access} disabled={busy !== null}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
</fieldset>
{:else}
<p class="workspace-empty-copy">No repository targets.</p>
{/each}
</div>
<button
class="workspace-secondary-button"
type="button"
onclick={() => addTarget()}
>Add target</button>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !targetCandidateValid}>
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</details>
{#if mergeRequests.length > 0}
<BevelLine decorative />
<details class="ticket-control-card">
<summary>MR requirement evidence</summary>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
<ul>
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
</ul>
{/if}
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</details>
{/if}
<BevelLine decorative />
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
{#if mergeRequests.length > 0}
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
<article class="ticket-control-card">
<p><strong>{mergeRequest.repository_key}: {mergeRequest.state}</strong></p>
<p>
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
to <code>{mergeRequest.selector_to}</code>
</p>
<p><strong>{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}:</strong> {summarySourceReviewStatus(mergeRequest)}</p>
{#if mergeRequest.integration_evidence_error !== null}
<p class="workspace-callout is-error"><strong>Integration evidence error:</strong> {mergeRequest.integration_evidence_error}</p>
{/if}
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
{#if mergeRequest.state === "merged"}
<p class="workspace-empty-copy">The merged source and integration approval are persisted evidence, not a live selector observation.</p>
{:else}
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
{/if}
<a
class="workspace-secondary-button"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>Open Merge Request</a>
</article>
{/each}
{:else}
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p>
<section class="ticket-control-card ticket-metadata" aria-label="Ticket metadata">
<dl>
<dt>Created by</dt><dd>{ticket.created_by ?? "Unknown author"}</dd>
<dt>Created</dt><dd><time datetime={ticket.created_at ?? undefined}>{prettyDate(ticket.created_at)}</time></dd>
<dt>Updated</dt><dd><time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time></dd>
</dl>
<button class="workspace-secondary-button" type="button" onclick={() => { editTitle = ticket.title; editing = !editing; }}>
{editing ? "Cancel title edit" : "Edit title"}
</button>
{#if editing}
<form class="ticket-editor" aria-label="Edit ticket title" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required disabled={busy !== null} /></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save title"}
</button>
</form>
{/if}
</section>
@@ -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."));