mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
[codex] Add runtime lifecycle recovery and live issue visibility (#4419)
This commit is contained in:
parent
9a8d219949
commit
5a0c1979cf
121 changed files with 9625 additions and 2044 deletions
|
|
@ -110,6 +110,8 @@ const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string
|
|||
cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
|
||||
};
|
||||
|
||||
const RUN_LOG_PAGE_BYTES = 256_000;
|
||||
|
||||
const REDACTED_ENV_VALUE = "***REDACTED***";
|
||||
const SECRET_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
|
@ -3473,6 +3475,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
const [logLoading, setLogLoading] = useState(!!run.logRef);
|
||||
const [logError, setLogError] = useState<string | null>(null);
|
||||
const [logOffset, setLogOffset] = useState(0);
|
||||
const [hasMoreLog, setHasMoreLog] = useState(false);
|
||||
const [loadingMoreLog, setLoadingMoreLog] = useState(false);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [isStreamingConnected, setIsStreamingConnected] = useState(false);
|
||||
const [transcriptMode, setTranscriptMode] = useState<TranscriptMode>("nice");
|
||||
|
|
@ -3627,6 +3631,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
pendingLogLineRef.current = "";
|
||||
setLogLines([]);
|
||||
setLogOffset(0);
|
||||
setHasMoreLog(false);
|
||||
setLoadingMoreLog(false);
|
||||
setLogError(null);
|
||||
|
||||
if (!run.logRef && !isLive) {
|
||||
|
|
@ -3637,25 +3643,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
}
|
||||
|
||||
setLogLoading(true);
|
||||
const firstLimit =
|
||||
typeof run.logBytes === "number" && run.logBytes > 0
|
||||
? Math.min(Math.max(run.logBytes + 1024, 256_000), 2_000_000)
|
||||
: 256_000;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
let offset = 0;
|
||||
let first = true;
|
||||
while (!cancelled) {
|
||||
const result = await heartbeatsApi.log(run.id, offset, first ? firstLimit : 256_000);
|
||||
if (cancelled) break;
|
||||
appendLogContent(result.content, result.nextOffset === undefined);
|
||||
const next = result.nextOffset ?? offset + result.content.length;
|
||||
setLogOffset(next);
|
||||
offset = next;
|
||||
first = false;
|
||||
if (result.nextOffset === undefined || isLive) break;
|
||||
}
|
||||
const result = await heartbeatsApi.log(run.id, 0, RUN_LOG_PAGE_BYTES);
|
||||
if (cancelled) return;
|
||||
appendLogContent(result.content, result.nextOffset === undefined);
|
||||
const next = result.nextOffset ?? result.content.length;
|
||||
setLogOffset(next);
|
||||
setHasMoreLog(!isLive && result.nextOffset !== undefined);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
if (isLive && isRunLogUnavailable(err)) {
|
||||
|
|
@ -3675,6 +3670,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
};
|
||||
}, [run.id, run.logRef, run.logBytes, isLive]);
|
||||
|
||||
async function loadMorePersistedLog() {
|
||||
if (loadingMoreLog || !hasMoreLog) return;
|
||||
setLoadingMoreLog(true);
|
||||
setLogError(null);
|
||||
try {
|
||||
const result = await heartbeatsApi.log(run.id, logOffset, RUN_LOG_PAGE_BYTES);
|
||||
appendLogContent(result.content, result.nextOffset === undefined);
|
||||
const next = result.nextOffset ?? logOffset + result.content.length;
|
||||
setLogOffset(next);
|
||||
setHasMoreLog(result.nextOffset !== undefined);
|
||||
} catch (err) {
|
||||
setLogError(err instanceof Error ? err.message : "Failed to load more run log");
|
||||
} finally {
|
||||
setLoadingMoreLog(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for live updates
|
||||
useEffect(() => {
|
||||
if (!isLive || isStreamingConnected) return;
|
||||
|
|
@ -3941,6 +3953,25 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
streaming={isLive}
|
||||
emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."}
|
||||
/>
|
||||
{hasMoreLog && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={loadMorePersistedLog}
|
||||
disabled={loadingMoreLog}
|
||||
>
|
||||
{loadingMoreLog ? "Loading..." : "Load more log"}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Showing the first {Math.round(logOffset / 1024).toLocaleString("en-US")} KB
|
||||
{typeof run.logBytes === "number" && run.logBytes > 0
|
||||
? ` of ${Math.round(run.logBytes / 1024).toLocaleString("en-US")} KB`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{logError && (
|
||||
<div className="mt-3 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-3 py-2 text-xs text-red-700 dark:text-red-300">
|
||||
{logError}
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ export function Dashboard() {
|
|||
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
|
||||
{/* Status icon - left column on mobile */}
|
||||
<span className="shrink-0 sm:hidden">
|
||||
<StatusIcon status={issue.status} />
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
|
||||
</span>
|
||||
|
||||
{/* Right column on mobile: title + metadata stacked */}
|
||||
|
|
@ -365,7 +365,7 @@ export function Dashboard() {
|
|||
{issue.title}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
|
||||
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} /></span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
|
|
|
|||
64
ui/src/pages/DashboardLive.tsx
Normal file
64
ui/src/pages/DashboardLive.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect } from "react";
|
||||
import { ArrowLeft, RadioTower } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
|
||||
const DASHBOARD_LIVE_RUN_LIMIT = 50;
|
||||
|
||||
export function DashboardLive() {
|
||||
const { selectedCompanyId, companies } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Live runs" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={RadioTower}
|
||||
message={companies.length === 0 ? "Create a company to view live runs." : "Select a company to view live runs."}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-normal text-foreground">Live agent runs</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Active runs first, followed by the most recent completed runs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Showing up to {DASHBOARD_LIVE_RUN_LIMIT}</div>
|
||||
</div>
|
||||
|
||||
<ActiveAgentsPanel
|
||||
companyId={selectedCompanyId}
|
||||
title="Active / recent"
|
||||
minRunCount={DASHBOARD_LIVE_RUN_LIMIT}
|
||||
fetchLimit={DASHBOARD_LIVE_RUN_LIMIT}
|
||||
cardLimit={DASHBOARD_LIVE_RUN_LIMIT}
|
||||
gridClassName="gap-3 md:grid-cols-2 2xl:grid-cols-3"
|
||||
cardClassName="h-[420px]"
|
||||
emptyMessage="No active or recent agent runs."
|
||||
queryScope="dashboard-live"
|
||||
showMoreLink={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from "../components/WorkspaceRuntimeControls";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
|
||||
|
|
@ -271,13 +272,7 @@ function ExecutionWorkspaceIssuesList({
|
|||
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 liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ describe("FailedRunInboxRow", () => {
|
|||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
lastOutputAt: null,
|
||||
lastOutputSeq: 0,
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: null,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
countActiveIssueFilters,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import {
|
||||
|
|
@ -826,6 +827,7 @@ export function Inbox() {
|
|||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
|
|
@ -845,12 +847,12 @@ export function Inbox() {
|
|||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||
const visibleMineIssues = useMemo(
|
||||
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true),
|
||||
[mineIssues, issueFilters, currentUserId],
|
||||
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds),
|
||||
[mineIssues, issueFilters, currentUserId, liveIssueIds],
|
||||
);
|
||||
const visibleTouchedIssues = useMemo(
|
||||
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true),
|
||||
[touchedIssues, issueFilters, currentUserId],
|
||||
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds),
|
||||
[touchedIssues, issueFilters, currentUserId, liveIssueIds],
|
||||
);
|
||||
const unreadTouchedIssues = useMemo(
|
||||
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
|
|
@ -1004,14 +1006,6 @@ export function Inbox() {
|
|||
),
|
||||
[heartbeatRuns, dismissedAtByKey],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of liveRuns ?? []) {
|
||||
if (run.issueId) ids.add(run.issueId);
|
||||
}
|
||||
return ids;
|
||||
}, [liveRuns]);
|
||||
|
||||
const approvalsToRender = useMemo(() => {
|
||||
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter, currentUserId);
|
||||
if (tab === "mine") {
|
||||
|
|
@ -1159,12 +1153,14 @@ export function Inbox() {
|
|||
issueFilters,
|
||||
currentUserId,
|
||||
enableRoutineVisibilityFilter: true,
|
||||
liveIssueIds,
|
||||
}),
|
||||
[
|
||||
archivedSearchIssues,
|
||||
currentUserId,
|
||||
filteredWorkItems,
|
||||
issueFilters,
|
||||
liveIssueIds,
|
||||
normalizedSearchQuery,
|
||||
remoteIssueSearchResults,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export function InstanceExperimentalSettings() {
|
|||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
const enableIssueGraphLivenessAutoRecovery =
|
||||
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
|
|
@ -128,6 +130,28 @@ export function InstanceExperimentalSettings() {
|
|||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Auto-Create Issue Recovery Tasks</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Let the heartbeat scheduler create recovery issues for issue dependency chains that have been stalled for
|
||||
at least 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableIssueGraphLivenessAutoRecovery}
|
||||
onCheckedChange={() =>
|
||||
toggleMutation.mutate({
|
||||
enableIssueGraphLivenessAutoRecovery: !enableIssueGraphLivenessAutoRecovery,
|
||||
})
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle issue graph liveness auto-recovery"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,7 +229,9 @@ vi.mock("../components/ScrollToBottom", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../components/StatusIcon", () => ({
|
||||
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
StatusIcon: ({ status, blockerAttention }: { status: string; blockerAttention?: Issue["blockerAttention"] }) => (
|
||||
<span data-status-icon-state={blockerAttention?.state}>{status}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../components/PriorityIcon", () => ({
|
||||
|
|
@ -814,6 +816,31 @@ describe("IssueDetail", () => {
|
|||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes blocker attention to the issue detail header status icon", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
status: "blocked",
|
||||
blockerAttention: {
|
||||
state: "covered",
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
},
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('[data-status-icon-state="covered"]')?.textContent).toBe("blocked");
|
||||
});
|
||||
|
||||
it("refreshes subtree pause state after resuming a hold", async () => {
|
||||
const childIssue = createIssue({
|
||||
id: "child-1",
|
||||
|
|
@ -1150,11 +1177,24 @@ describe("IssueDetail", () => {
|
|||
.find((element) =>
|
||||
typeof element.className === "string"
|
||||
&& element.className.includes("overflow-y-auto")
|
||||
&& element.textContent?.includes("Reason (required)"),
|
||||
&& element.textContent?.includes("Reason (optional)"),
|
||||
);
|
||||
expect(bodyScrollRegion?.className).toContain("min-h-0");
|
||||
expect(bodyScrollRegion?.className).toContain("overscroll-contain");
|
||||
|
||||
const cancelApplyButton = Array.from(dialogContent!.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Cancel 24 issues") as HTMLButtonElement | undefined;
|
||||
expect(cancelApplyButton).toBeTruthy();
|
||||
expect(cancelApplyButton!.disabled).toBe(true);
|
||||
|
||||
const confirmationCheckbox = dialogContent!.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
|
||||
expect(confirmationCheckbox).toBeTruthy();
|
||||
await act(async () => {
|
||||
confirmationCheckbox!.click();
|
||||
});
|
||||
await flushReact();
|
||||
expect(cancelApplyButton!.disabled).toBe(false);
|
||||
|
||||
const footer = Array.from(dialogContent!.querySelectorAll("div"))
|
||||
.find((element) =>
|
||||
typeof element.className === "string"
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@ function IssueDetailLoadingState({
|
|||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
{headerSeed ? (
|
||||
<>
|
||||
<StatusIcon status={headerSeed.status} />
|
||||
<StatusIcon status={headerSeed.status} blockerAttention={headerSeed.blockerAttention} />
|
||||
<PriorityIcon priority={headerSeed.priority} />
|
||||
{identifier ? (
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
|
||||
|
|
@ -692,6 +692,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
|
||||
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
|
||||
const followUpCommentIds = new Set<string>();
|
||||
const agentIdByRunId = new Map<string, string>();
|
||||
|
||||
for (const run of resolvedLinkedRuns) {
|
||||
|
|
@ -710,10 +711,22 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
interruptedRunId,
|
||||
});
|
||||
}
|
||||
for (const evt of resolvedActivity) {
|
||||
if (evt.action !== "issue.comment_added") continue;
|
||||
const details = evt.details ?? {};
|
||||
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
||||
if (!commentId) continue;
|
||||
if (details["followUpRequested"] === true || details["resumeIntent"] === true) {
|
||||
followUpCommentIds.add(commentId);
|
||||
}
|
||||
}
|
||||
|
||||
return comments.map((comment) => {
|
||||
const meta = runMetaByCommentId.get(comment.id);
|
||||
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
|
||||
if (followUpCommentIds.has(comment.id)) {
|
||||
nextComment.followUpRequested = true;
|
||||
}
|
||||
const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null;
|
||||
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
|
||||
queuedTargetRunId,
|
||||
|
|
@ -2702,7 +2715,7 @@ export function IssueDetail() {
|
|||
const canApplyTreeControl =
|
||||
Boolean(treeControlPreview)
|
||||
&& !treeControlPreviewLoading
|
||||
&& (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed));
|
||||
&& (treeControlMode !== "cancel" || treeControlCancelConfirmed);
|
||||
const attachmentUploadButton = (
|
||||
<>
|
||||
<input
|
||||
|
|
@ -2840,6 +2853,7 @@ export function IssueDetail() {
|
|||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
blockerAttention={issue.blockerAttention}
|
||||
onChange={(status) => updateIssue.mutate({ status })}
|
||||
/>
|
||||
<PriorityIcon
|
||||
|
|
@ -3448,7 +3462,7 @@ export function IssueDetail() {
|
|||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{treeControlMode === "cancel" ? "Reason (required)" : "Reason (optional)"}
|
||||
Reason (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
value={treeControlReason}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export function MyIssues() {
|
|||
title={issue.title}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
leading={
|
||||
<StatusIcon status={issue.status} />
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
|
||||
}
|
||||
trailing={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
|||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
|
||||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { projectRouteRef } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
|
|
@ -175,13 +176,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
|||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const liveIssueIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of liveRuns ?? []) {
|
||||
if (run.issueId) ids.add(run.issueId);
|
||||
}
|
||||
return ids;
|
||||
}, [liveRuns]);
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.listByProject(companyId, projectId),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useToastActions } from "../context/ToastContext";
|
|||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
|
|
@ -492,13 +493,7 @@ export function Routines() {
|
|||
() => new Map((projects ?? []).map((project) => [project.id, project])),
|
||||
[projects],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of liveRuns ?? []) {
|
||||
if (run.issueId) ids.add(run.issueId);
|
||||
}
|
||||
return ids;
|
||||
}, [liveRuns]);
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
const routineGroups = useMemo(
|
||||
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
|
||||
[agentById, projectById, routineViewState.groupBy, routines],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue