mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
Add execution workspace issues tab
This commit is contained in:
parent
93355bae6b
commit
1cbb0a5e34
3 changed files with 532 additions and 431 deletions
|
|
@ -161,6 +161,8 @@ function boardRoutes() {
|
||||||
<Route path="routines" element={<Routines />} />
|
<Route path="routines" element={<Routines />} />
|
||||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="goals" element={<Goals />} />
|
<Route path="goals" element={<Goals />} />
|
||||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
||||||
|
|
@ -349,6 +351,8 @@ export function App() {
|
||||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
|
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path=":companyPrefix" element={<Layout />}>
|
<Route path=":companyPrefix" element={<Layout />}>
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,23 @@ import {
|
||||||
describe("company routes", () => {
|
describe("company routes", () => {
|
||||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
||||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
||||||
|
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/issues")).toBe(true);
|
||||||
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
||||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
||||||
"/PAP/execution-workspaces/workspace-123",
|
"/PAP/execution-workspaces/workspace-123",
|
||||||
);
|
);
|
||||||
|
expect(applyCompanyPrefix("/execution-workspaces/workspace-123/issues", "PAP")).toBe(
|
||||||
|
"/PAP/execution-workspaces/workspace-123/issues",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
||||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
||||||
"/execution-workspaces/workspace-123",
|
"/execution-workspaces/workspace-123",
|
||||||
);
|
);
|
||||||
|
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/configuration")).toBe(
|
||||||
|
"/execution-workspaces/workspace-123/configuration",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "@/lib/router";
|
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { ExecutionWorkspace, Project, ProjectWorkspace } from "@paperclipai/shared";
|
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||||
import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react";
|
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { CopyText } from "../components/CopyText";
|
import { CopyText } from "../components/CopyText";
|
||||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { IssuesList } from "../components/IssuesList";
|
||||||
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
@ -29,6 +34,18 @@ type WorkspaceFormState = {
|
||||||
workspaceRuntime: string;
|
workspaceRuntime: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExecutionWorkspaceTab = "configuration" | "issues";
|
||||||
|
|
||||||
|
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
|
||||||
|
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
|
||||||
|
const tab = segments[executionWorkspacesIndex + 2];
|
||||||
|
if (tab === "issues") return "issues";
|
||||||
|
if (tab === "configuration") return "configuration";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function isSafeExternalUrl(value: string | null | undefined) {
|
function isSafeExternalUrl(value: string | null | undefined) {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
try {
|
try {
|
||||||
|
|
@ -214,8 +231,79 @@ function WorkspaceLink({
|
||||||
return <Link to={projectWorkspaceUrl(project, workspace.id)} className="hover:underline">{workspace.name}</Link>;
|
return <Link to={projectWorkspaceUrl(project, workspace.id)} className="hover:underline">{workspace.name}</Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ExecutionWorkspaceIssuesList({
|
||||||
|
companyId,
|
||||||
|
workspaceId,
|
||||||
|
issues,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
issues: Issue[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
project: Project | null;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(companyId),
|
||||||
|
queryFn: () => agentsApi.list(companyId),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: liveRuns } = useQuery({
|
||||||
|
queryKey: queryKeys.liveRuns(companyId),
|
||||||
|
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||||
|
enabled: !!companyId,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveIssueIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const run of liveRuns ?? []) {
|
||||||
|
if (run.issueId) ids.add(run.issueId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [liveRuns]);
|
||||||
|
|
||||||
|
const updateIssue = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspaceId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
if (project?.id) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, project.id) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectOptions = useMemo(
|
||||||
|
() => (project ? [{ id: project.id, name: project.name, workspaces: project.workspaces ?? [] }] : undefined),
|
||||||
|
[project],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IssuesList
|
||||||
|
issues={issues}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
agents={agents}
|
||||||
|
projects={projectOptions}
|
||||||
|
liveIssueIds={liveIssueIds}
|
||||||
|
projectId={project?.id}
|
||||||
|
viewStateKey={`paperclip:execution-workspace-view:${workspaceId}`}
|
||||||
|
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExecutionWorkspaceDetail() {
|
export function ExecutionWorkspaceDetail() {
|
||||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
|
|
@ -223,6 +311,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
|
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||||
|
const activeTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null;
|
||||||
|
|
||||||
const workspaceQuery = useQuery({
|
const workspaceQuery = useQuery({
|
||||||
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
|
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
|
||||||
|
|
@ -357,6 +446,24 @@ export function ExecutionWorkspaceDetail() {
|
||||||
}
|
}
|
||||||
if (!workspace || !form || !initialState) return null;
|
if (!workspace || !form || !initialState) return null;
|
||||||
|
|
||||||
|
if (workspaceId && activeTab === null) {
|
||||||
|
let cachedTab: ExecutionWorkspaceTab = "configuration";
|
||||||
|
try {
|
||||||
|
const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`);
|
||||||
|
if (storedTab === "issues" || storedTab === "configuration") {
|
||||||
|
cachedTab = storedTab;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return <Navigate to={`/execution-workspaces/${workspaceId}/${cachedTab}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab);
|
||||||
|
} catch {}
|
||||||
|
navigate(`/execution-workspaces/${workspace.id}/${tab}`);
|
||||||
|
};
|
||||||
|
|
||||||
const saveChanges = () => {
|
const saveChanges = () => {
|
||||||
const validationError = validateForm(form);
|
const validationError = validateForm(form);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
|
|
@ -393,8 +500,6 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
|
||||||
<div className="min-w-0 space-y-4 sm:space-y-6">
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
|
|
@ -404,9 +509,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
||||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
|
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
|
||||||
<span className="hidden sm:inline"> These settings stay
|
<span className="hidden sm:inline"> These settings stay attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, and runtime-service behavior in sync with the actual workspace being reused.</span>
|
||||||
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
|
|
||||||
and runtime-service behavior in sync with the actual workspace being reused.</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
||||||
|
|
@ -420,6 +523,33 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
|
||||||
|
<PageTabBar
|
||||||
|
items={[
|
||||||
|
{ value: "configuration", label: "Configuration" },
|
||||||
|
{ value: "issues", label: "Issues" },
|
||||||
|
]}
|
||||||
|
align="start"
|
||||||
|
value={activeTab ?? "configuration"}
|
||||||
|
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{activeTab === "configuration" ? (
|
||||||
|
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||||
|
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
Configuration
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold">Workspace settings</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator className="my-5" />
|
<Separator className="my-5" />
|
||||||
|
|
||||||
|
|
@ -551,7 +681,6 @@ export function ExecutionWorkspaceDetail() {
|
||||||
const checked = event.target.checked;
|
const checked = event.target.checked;
|
||||||
setForm((current) => {
|
setForm((current) => {
|
||||||
if (!current) return current;
|
if (!current) return current;
|
||||||
// When unchecking "inherit" and the field is empty, copy inherited config as a starting point
|
|
||||||
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
||||||
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
||||||
}
|
}
|
||||||
|
|
@ -627,7 +756,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Derived from">
|
<DetailRow label="Derived from">
|
||||||
{derivedWorkspace ? (
|
{derivedWorkspace ? (
|
||||||
<Link to={`/execution-workspaces/${derivedWorkspace.id}`} className="hover:underline">
|
<Link to={`/execution-workspaces/${derivedWorkspace.id}/configuration`} className="hover:underline">
|
||||||
{derivedWorkspace.name}
|
{derivedWorkspace.name}
|
||||||
</Link>
|
</Link>
|
||||||
) : workspace.derivedFromExecutionWorkspaceId ? (
|
) : workspace.derivedFromExecutionWorkspaceId ? (
|
||||||
|
|
@ -806,56 +935,17 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
|
|
||||||
<h2 className="text-lg font-semibold">Issues using this workspace</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Any issue attached to this execution workspace appears here so you can review the full session context before reusing or closing it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusPill>{linkedIssues.length} linked</StatusPill>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-4" />
|
|
||||||
{linkedIssuesQuery.isLoading ? (
|
|
||||||
<p className="text-sm text-muted-foreground">Loading linked issues…</p>
|
|
||||||
) : linkedIssuesQuery.error ? (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{linkedIssuesQuery.error instanceof Error
|
|
||||||
? linkedIssuesQuery.error.message
|
|
||||||
: "Failed to load linked issues."}
|
|
||||||
</p>
|
|
||||||
) : linkedIssues.length > 0 ? (
|
|
||||||
<div className="-mx-1 flex flex-col gap-3 px-1 pb-1 sm:flex-row sm:overflow-x-auto">
|
|
||||||
{linkedIssues.map((issue) => (
|
|
||||||
<Link
|
|
||||||
key={issue.id}
|
|
||||||
to={issueUrl(issue)}
|
|
||||||
className="rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20 sm:min-w-72"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 space-y-1">
|
|
||||||
<div className="font-mono text-xs text-muted-foreground">
|
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
||||||
</div>
|
|
||||||
<div className="line-clamp-2 text-sm font-medium">{issue.title}</div>
|
|
||||||
</div>
|
|
||||||
<StatusPill className="shrink-0">{issue.status}</StatusPill>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
|
||||||
<span className="uppercase tracking-[0.16em]">{issue.priority}</span>
|
|
||||||
<span>{formatDateTime(issue.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No issues are currently linked to this execution workspace.</p>
|
<ExecutionWorkspaceIssuesList
|
||||||
|
companyId={workspace.companyId}
|
||||||
|
workspaceId={workspace.id}
|
||||||
|
issues={linkedIssues}
|
||||||
|
isLoading={linkedIssuesQuery.isLoading}
|
||||||
|
error={linkedIssuesQuery.error as Error | null}
|
||||||
|
project={project}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<ExecutionWorkspaceCloseDialog
|
<ExecutionWorkspaceCloseDialog
|
||||||
workspaceId={workspace.id}
|
workspaceId={workspace.id}
|
||||||
workspaceName={workspace.name}
|
workspaceName={workspace.name}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue