merge: integrate orchestration
# Conflicts: # crates/flow/src/builtin.rs # crates/manifest/src/profile.rs # crates/worker/src/prompt/catalog.rs # crates/worker/src/prompt/system.rs # resources/flows/coder-review.dcdl # resources/prompts/role/coder.md # resources/prompts/role/orchestrator.md # web/workspace/src/lib/workspace/console/worker-console.ui.test.ts # web/workspace/src/lib/workspace/styles/tickets.css # web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte # web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts
This commit is contained in:
@@ -19,6 +19,7 @@ export type TicketListResponse = {
|
||||
workspace_id: string;
|
||||
limit: number;
|
||||
items: Array<TicketSummary>;
|
||||
page: QueryPage;
|
||||
invalid_records: Array<InvalidProjectRecord>;
|
||||
record_authority: string;
|
||||
};
|
||||
|
||||
@@ -178,7 +178,9 @@
|
||||
align-content: start;
|
||||
gap: 0.55rem;
|
||||
min-height: 0;
|
||||
max-height: min(68vh, 48rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.6rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
@@ -189,6 +191,27 @@
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-lane-page-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0.45rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-lane-page-error {
|
||||
align-items: center;
|
||||
color: var(--danger);
|
||||
}
|
||||
.ticket-lane-page-error button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--bg-raised);
|
||||
color: inherit;
|
||||
padding: 0.2rem 0.45rem;
|
||||
}
|
||||
.ticket-card {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
|
||||
@@ -11,16 +11,9 @@ import type {
|
||||
} from "../../generated/ticket-api.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
readTextFile(path: string): Promise<string>;
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assertIncludes(actual: string, expected: string): void {
|
||||
if (!actual.includes(expected)) {
|
||||
throw new Error(`expected source to include ${JSON.stringify(expected)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
@@ -115,29 +108,3 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
|
||||
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestration actions", async () => {
|
||||
const panelSource = await Deno.readTextFile(
|
||||
"src/routes/w/[workspaceId]/tickets/+page.svelte",
|
||||
);
|
||||
const detailSource = await Deno.readTextFile(
|
||||
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
);
|
||||
|
||||
assertIncludes(
|
||||
panelSource,
|
||||
'workspaceApiPath(data.workspaceId, "/orchestrator")',
|
||||
);
|
||||
assertIncludes(panelSource, '{ method: "POST" }');
|
||||
assertIncludes(panelSource, "Start Orchestrator");
|
||||
assertIncludes(panelSource, "orchestrator.data?.online");
|
||||
assertIncludes(panelSource, "lane.tickets.slice(0, lane.visibleCount)");
|
||||
assertIncludes(
|
||||
panelSource,
|
||||
"onscroll={(event) => handleLaneScroll(event, lane.id)}",
|
||||
);
|
||||
assertIncludes(panelSource, "Scroll for");
|
||||
assertIncludes(detailSource, "{#if orchestratorOnline}");
|
||||
assertIncludes(detailSource, "!orchestratorOnline");
|
||||
assertIncludes(detailSource, "Orchestrator offline");
|
||||
});
|
||||
|
||||
@@ -1,44 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import type { TicketListResponse } from "$lib/generated/ticket-api";
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type {
|
||||
QueryPage,
|
||||
TicketListResponse,
|
||||
TicketSummary,
|
||||
} from "$lib/generated/ticket-api";
|
||||
import {
|
||||
nextTicketLaneVisibleCount,
|
||||
TICKET_LANE_PAGE_SIZE,
|
||||
ticketLanes,
|
||||
type TicketLane,
|
||||
type TicketLaneId,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "$lib/workspace/tickets/ticket-panel";
|
||||
import "$lib/workspace/styles/tickets.css";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
type VisibleTicketLane = TicketLane & { visibleCount: number };
|
||||
type LaneState = {
|
||||
states: string[];
|
||||
tickets: TicketSummary[];
|
||||
page: QueryPage;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const { data } = $props<{
|
||||
data: {
|
||||
workspaceId: string;
|
||||
tickets: ApiResult<TicketListResponse>;
|
||||
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||
};
|
||||
}>();
|
||||
|
||||
let lanes = $state<VisibleTicketLane[]>(
|
||||
untrack(() =>
|
||||
ticketLanes(data.tickets.data?.items ?? []).map((lane) => ({
|
||||
...lane,
|
||||
visibleCount: Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length),
|
||||
}))
|
||||
let { data }: { data: PageData } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
let laneState = $state<Record<string, LaneState>>(
|
||||
Object.fromEntries(
|
||||
Object.entries(data.ticketLanes).map(([laneId, lane]) => [
|
||||
laneId,
|
||||
{
|
||||
states: [...lane.states],
|
||||
tickets: lane.response.items,
|
||||
page: lane.response.page,
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||
untrack(() => data.orchestrator),
|
||||
);
|
||||
let orchestratorStarting = $state(false);
|
||||
const displayedTicketCount = $derived(
|
||||
lanes.reduce((count, lane) => count + lane.visibleCount, 0),
|
||||
const tickets = $derived(
|
||||
Object.values(laneState).flatMap((lane) => lane.tickets),
|
||||
);
|
||||
const LANE_LOAD_THRESHOLD_PX = 96;
|
||||
const lanes = $derived(ticketLanes(tickets));
|
||||
|
||||
function mergeTickets(
|
||||
current: TicketSummary[],
|
||||
incoming: TicketSummary[],
|
||||
): TicketSummary[] {
|
||||
const byId = new Map(current.map((ticket) => [ticket.id, ticket]));
|
||||
for (const ticket of incoming) byId.set(ticket.id, ticket);
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
async function loadMore(laneId: string): Promise<void> {
|
||||
const lane = laneState[laneId];
|
||||
if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) {
|
||||
return;
|
||||
}
|
||||
lane.loading = true;
|
||||
lane.error = null;
|
||||
try {
|
||||
const search = new URLSearchParams({
|
||||
limit: "30",
|
||||
states: lane.states.join(","),
|
||||
cursor: lane.page.next_cursor,
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`追加読み込みに失敗しました (${response.status})`);
|
||||
}
|
||||
const page = (await response.json()) as TicketListResponse;
|
||||
lane.tickets = mergeTickets(lane.tickets, page.items);
|
||||
lane.page = page.page;
|
||||
} catch (error) {
|
||||
lane.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
lane.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLaneScroll(event: Event, laneId: string): void {
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const remaining =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (remaining <= 96) void loadMore(laneId);
|
||||
}
|
||||
|
||||
async function startOrchestrator() {
|
||||
if (orchestratorStarting || orchestrator.data?.online) return;
|
||||
@@ -56,37 +107,20 @@
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function revealNextTickets(laneId: TicketLaneId): void {
|
||||
lanes = lanes.map((lane) =>
|
||||
lane.id === laneId
|
||||
? {
|
||||
...lane,
|
||||
visibleCount: nextTicketLaneVisibleCount(
|
||||
lane.visibleCount,
|
||||
lane.tickets.length,
|
||||
),
|
||||
}
|
||||
: lane
|
||||
);
|
||||
}
|
||||
|
||||
function handleLaneScroll(event: Event, laneId: TicketLaneId): void {
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop -
|
||||
element.clientHeight;
|
||||
if (distanceFromBottom <= LANE_LOAD_THRESHOLD_PX) {
|
||||
revealNextTickets(laneId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Tickets · Yoi</title></svelte:head>
|
||||
<svelte:head>
|
||||
<title>Tickets · {data.workspaceId}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="workspace-page ticket-panel-page">
|
||||
<header class="workspace-page-header ticket-panel-header">
|
||||
<div>
|
||||
<p class="workspace-eyebrow">Delivery</p>
|
||||
<h1>Tickets</h1>
|
||||
<p class="workspace-page-lede">
|
||||
Plan, route, review, and close work without leaving the workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ticket-panel-controls">
|
||||
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
|
||||
@@ -107,16 +141,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
||||
<strong>{displayedTicketCount}</strong>
|
||||
<span>tickets displayed</span>
|
||||
<strong>{tickets.length}</strong>
|
||||
<span>loaded tickets</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if data.tickets.error}
|
||||
<p class="workspace-callout is-error">Tickets: {data.tickets.error}</p>
|
||||
{/if}
|
||||
|
||||
{#if orchestrator.error}
|
||||
<p class="workspace-callout is-error">
|
||||
Orchestrator status: {orchestrator.error}
|
||||
@@ -129,24 +159,21 @@
|
||||
|
||||
<section class="ticket-kanban" aria-label="Ticket workflow board">
|
||||
{#each lanes as lane (lane.id)}
|
||||
{@const displayedTickets = lane.tickets.slice(0, lane.visibleCount)}
|
||||
{@const hasMore = lane.visibleCount < lane.tickets.length}
|
||||
{@const pagination = laneState[lane.id]}
|
||||
<section class="ticket-lane" data-state={lane.id}>
|
||||
<header class="ticket-lane-header">
|
||||
<div>
|
||||
<span class="ticket-state-dot"></span>
|
||||
<h2>{lane.label}</h2>
|
||||
</div>
|
||||
<span class="ticket-lane-count">
|
||||
{displayedTickets.length}{hasMore ? "+" : ""}
|
||||
</span>
|
||||
<span class="ticket-lane-count">{lane.tickets.length}</span>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="ticket-lane-cards"
|
||||
data-lane-id={lane.id}
|
||||
onscroll={(event) => handleLaneScroll(event, lane.id)}
|
||||
>
|
||||
{#each displayedTickets as ticket (ticket.id)}
|
||||
{#each lane.tickets as ticket (ticket.id)}
|
||||
<a
|
||||
class="ticket-card"
|
||||
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
|
||||
@@ -161,13 +188,15 @@
|
||||
{:else}
|
||||
<div class="ticket-lane-empty">No tickets</div>
|
||||
{/each}
|
||||
|
||||
{#if hasMore}
|
||||
<p class="ticket-lane-load-status" aria-live="polite">
|
||||
Scroll for {Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length - lane.visibleCount)} more
|
||||
</p>
|
||||
{:else if displayedTickets.length > 0}
|
||||
<p class="ticket-lane-load-status">All tickets displayed.</p>
|
||||
{#if pagination?.loading}
|
||||
<p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
|
||||
{:else if pagination?.error}
|
||||
<div class="ticket-lane-page-state ticket-lane-page-error" role="alert">
|
||||
<span>{pagination.error}</span>
|
||||
<button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
|
||||
</div>
|
||||
{:else if pagination && !pagination.page.has_more && lane.tickets.length > 0}
|
||||
<p class="ticket-lane-page-state">End of lane</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
import type { TicketListResponse } from "$lib/generated/ticket-api";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type { TicketListResponse } from "$lib/generated/ticket-api";
|
||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const LANE_STATES = {
|
||||
"ready-planning": ["ready", "planning"],
|
||||
"inprogress-queued": ["inprogress", "queued"],
|
||||
"done-closed": ["done", "closed"],
|
||||
} as const;
|
||||
|
||||
export type TicketLaneId = keyof typeof LANE_STATES;
|
||||
|
||||
export type TicketLanePage = {
|
||||
states: readonly string[];
|
||||
response: TicketListResponse;
|
||||
};
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const [tickets, orchestrator] = await Promise.all([
|
||||
loadJson<TicketListResponse>(
|
||||
fetch,
|
||||
`${workspaceApiPath(workspaceId, "/tickets")}?limit=1000`,
|
||||
const [entries, orchestrator] = await Promise.all([
|
||||
Promise.all(
|
||||
Object.entries(LANE_STATES).map(async ([laneId, states]) => {
|
||||
const search = new URLSearchParams({
|
||||
limit: "30",
|
||||
states: states.join(","),
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`failed to load ${laneId} Ticket lane (${response.status})`,
|
||||
);
|
||||
}
|
||||
return [
|
||||
laneId,
|
||||
{ states: [...states], response: await response.json() },
|
||||
] as const;
|
||||
}),
|
||||
),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
@@ -16,5 +45,12 @@ export const load = (async ({ fetch, params }) => {
|
||||
),
|
||||
]);
|
||||
|
||||
return { workspaceId, tickets, orchestrator };
|
||||
}) satisfies PageLoad;
|
||||
return {
|
||||
workspaceId,
|
||||
ticketLanes: Object.fromEntries(entries) as unknown as Record<
|
||||
TicketLaneId,
|
||||
TicketLanePage
|
||||
>,
|
||||
orchestrator,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
import type {
|
||||
RepositoryListResponse,
|
||||
RepositorySummary,
|
||||
TicketDetail,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
|
||||
@@ -65,7 +66,9 @@
|
||||
thread: MergeRequestThreadEvent[];
|
||||
};
|
||||
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
|
||||
state !== "done" && state !== "ready" && state !== "queued"
|
||||
);
|
||||
|
||||
const { data } = $props<{
|
||||
data: {
|
||||
@@ -115,6 +118,27 @@
|
||||
let resolution = $state("");
|
||||
let busy = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let readyOperationKey = $state<string | null>(null);
|
||||
const selectedRepository = $derived(
|
||||
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
|
||||
);
|
||||
const effectiveRefSelector = $derived(refSelector.trim() || selectedRepository?.default_ref || "");
|
||||
const targetCandidateValid = $derived(
|
||||
ticket.state === "planning" &&
|
||||
selectedRepository !== null &&
|
||||
(selectedRepository.diagnostics ?? []).length === 0 &&
|
||||
effectiveRefSelector.length > 0,
|
||||
);
|
||||
const persistedTargetValid = $derived(
|
||||
ticket.repository_id !== null &&
|
||||
ticket.ref_selector !== null &&
|
||||
(loadedRepositories?.items ?? []).some((repository: RepositorySummary) =>
|
||||
repository.id === ticket.repository_id && (repository.diagnostics ?? []).length === 0
|
||||
),
|
||||
);
|
||||
const implementationStartEligible = $derived(
|
||||
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
|
||||
);
|
||||
|
||||
const ticketPath = $derived(
|
||||
workspaceApiPath(
|
||||
@@ -180,6 +204,33 @@
|
||||
}, "PATCH");
|
||||
}
|
||||
|
||||
async function markReady() {
|
||||
if (!targetCandidateValid || busy) return;
|
||||
if (
|
||||
ticket.repository_id !== repositoryId ||
|
||||
(ticket.ref_selector ?? "") !== refSelector.trim()
|
||||
) {
|
||||
const saved = await mutate("target", "", {
|
||||
target: {
|
||||
action: "set",
|
||||
repository_id: repositoryId,
|
||||
ref_selector: refSelector.trim() || null,
|
||||
},
|
||||
}, "PATCH");
|
||||
if (!saved) return;
|
||||
}
|
||||
readyOperationKey ??= crypto.randomUUID();
|
||||
if (
|
||||
await mutate("ready", "/ready", {
|
||||
operation_key: readyOperationKey,
|
||||
reason: transitionReason.trim() || null,
|
||||
})
|
||||
) {
|
||||
readyOperationKey = null;
|
||||
transitionReason = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
@@ -329,16 +380,19 @@
|
||||
<p class="ticket-assignment-line">
|
||||
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
||||
</p>
|
||||
{#if orchestratorOnline}
|
||||
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
|
||||
{#if orchestratorOnline && implementationStartEligible}
|
||||
<p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
|
||||
<div class="ticket-role-actions">
|
||||
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="workspace-callout">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p>
|
||||
<p class="workspace-callout">
|
||||
{orchestratorOnline
|
||||
? "Validate and persist the repository target before starting a Ticket Worker."
|
||||
: "Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers."}
|
||||
</p>
|
||||
<div class="ticket-role-actions">
|
||||
<button class="workspace-primary-button" type="button" disabled>Coder</button>
|
||||
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -347,15 +401,15 @@
|
||||
<header><h2>Repository target</h2></header>
|
||||
<form class="ticket-control-form" onsubmit={saveTarget}>
|
||||
<label>Repository
|
||||
<select bind:value={repositoryId}>
|
||||
<select bind:value={repositoryId} disabled={ticket.state !== "planning"}>
|
||||
<option value="">Not assigned</option>
|
||||
{#each loadedRepositories?.items ?? [] as repository}
|
||||
<option value={repository.id}>{repository.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}>
|
||||
<label>Ref selector<input bind:value={refSelector} placeholder={selectedRepository?.default_ref ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
|
||||
{busy === "target" ? "Saving…" : "Save target"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -374,8 +428,15 @@
|
||||
Apply state
|
||||
</button>
|
||||
</form>
|
||||
{#if ticket.state === "ready"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}>
|
||||
{#if ticket.state === "planning"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy !== null || !targetCandidateValid} onclick={markReady}>
|
||||
{busy === "ready" ? "Marking ready…" : "Mark ready"}
|
||||
</button>
|
||||
{#if !targetCandidateValid}
|
||||
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
|
||||
{/if}
|
||||
{:else if ticket.state === "ready"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline || !persistedTargetValid} onclick={() => mutate("queue", "/queue", {})}>
|
||||
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user