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
@@ -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."));