mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
fix(ui): keep responder avatars visible through comment acknowledgement (#17885)
## Thinking Path > - Paperclip gives people a shared place to manage and talk with agents. > - Task chat and Agent Chat show the responding agent as soon as a person sends a message. > - Comment storage and run discovery finish in separate requests. > - Both chat surfaces removed the startup identity when comment storage finished. > - A slow run lookup therefore made the avatar disappear and appear again. > - This pull request keeps startup feedback until the run appears or a newer execution outcome ends admission. > - The agent stays visible while the response starts. ## Linked Issues or Issue Description **What happened?** The responding agent's avatar appeared after Send, disappeared when the human comment was saved, and appeared again when the run loaded. Existing conversations made the flash easy to see. **Expected behavior** Keep the agent identity visible across comment storage, queued admission, and runtime startup. Clear temporary startup feedback on failure, backoff, task completion, or a completed run. **Steps to reproduce** 1. Open an existing agent conversation. 2. Send another message. 3. Let the comment POST finish before the task and live-run reads finish. 4. Observe the responder identity disappear during that gap. **Paperclip version** Source baseline: `db70c5acd92f50fa37fb4f0dea3da0e7685089a2`. **Deployment mode** Local trusted mode with an isolated database. The browser test used the real app, APIs, and native runner with a deterministic Codex provider. It held read responses to expose the acknowledgement gap. It did not call a live model provider. Related: #4485 addressed human-comment alignment during session loading. This change addresses agent-response startup. ## What Changed - Share submission-to-run state between the task and classic chat surfaces. - Keep the identity mounted after comment acknowledgement. End startup on a visible run or a newer admission outcome. - Keep old execution projections and older loaded history from acknowledging a new response. Retain slow-start refresh and uncertain-save handling. - Extend the startup tests through the missing acknowledgement interval on desktop and mobile. Add existing-thread, paused-agent, fast-completion, and clock-skew cases for both surfaces. - Match completed runs to the saved comment and its server time. Use the shared status rules and server-reported reporting-chain health for known agent blockers and confirmed task pauses. Keep submitted feedback visible through pause-state read errors. No browser/server clock comparison is used. - Clarify the identity continuity rule in `DESIGN.md`. ## Verification - Red: the actual Send tests fail against the original components in eight cases. - Green: both chat suites pass, 420 tests total. Extra red/green cases cover paused or ineligible agents, missing/terminated managers, reporting cycles, browser clocks five minutes ahead or behind, and pause-state read failures. - Chromium on an existing conversation: the original avatar appeared at 58 ms and disappeared at 326 ms. On final commit `f9602e3e48`, the avatar appeared at 68 ms and stayed visible through the same held-read interval. Frame sampling found no disappearance after it appeared. - Native fixture turns succeeded and stored their final provider replies. Repeated the existing-thread turn and normal navigation/reload on final commit `f9602e3e48`: both runs succeeded, both replies were stored, and the conversation returned to its waiting state. - `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, and the final UI typecheck passed. - `pnpm test:run` was attempted. The host exhausted PostgreSQL semaphores. The supported external test database let some suites run, but other suites still attempted embedded clusters and failed or skipped setup. The run was stopped after repeated setup failures. - The broader UI run passed 9,251 tests and failed one existing connection-authentication test. The same test fails on an untouched copy of the base commit. This PR does not change that component or test. Full-suite CI remains the complete-suite check. - Final-head CI attempt 1 lost seven workers to shutdown or lost communication and hit one timing assertion in an unchanged scheduler test. Retried only failed jobs once on the same commit; the scheduler test and interrupted jobs recovered. All retried jobs and both aggregate gates passed. Final CI is green on `f9602e3e48c75580935465eb0b4713fc7a4bcc56`. ## Risks Low scope: this changes client startup feedback only. There are no API, data, auth, or token changes. If run discovery is slow, the existing slow-start message remains visible until current execution evidence arrives. Completed runs are matched to the submitted comment by consumption identity or server timestamps, so loading old history and browser clock differences cannot end its feedback incorrectly. ## Model Used OpenAI Codex, GPT-6 family. This session does not expose the exact deployment ID or context-window limit. It used reasoning, code editing, command execution, and Chromium automation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (420 relevant chat tests; the full-suite setup limit is recorded above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e71a23c01c
commit
eda2a03bfe
5 files changed
+248
-74
No files matched your search
@@ -89,6 +89,8 @@ that relayed a message from another channel.
|
||||
|
||||
Keep the runner turn's avatar, name, Working header and current-activity slot
|
||||
in place from message submission through queued admission and native startup.
|
||||
Saving the human comment does not end startup feedback. Keep the identity mounted
|
||||
until its run appears or a newer execution outcome ends admission.
|
||||
Runtime discovery must not replace the turn with a generic spinner or a
|
||||
"run started" receipt. Queued and running headers use the same spacing.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WorkspaceBaseRefRecoveryNotice } from "./WorkspaceBaseRefRecoveryNotice
|
||||
import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "./DispositionRecoveryNotice";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { TaskChatRunnerTurn } from "./task-chat/TaskChatRunnerTurn";
|
||||
import { useRunnerSubmission } from "@/hooks/useRunnerSubmission";
|
||||
import type { ComposerRunSettings } from "./task-chat/composer-run-settings";
|
||||
import { ComposerRunSettingsPicker } from "./task-chat/ComposerRunSettingsPicker";
|
||||
import { ComposerAddMenu, ComposerModeChip } from "./task-chat/ComposerAddMenu";
|
||||
@@ -6097,47 +6098,39 @@ export function IssueChatThread({
|
||||
return true;
|
||||
}
|
||||
|
||||
const [submissionPending, setSubmissionPending] = useState(false);
|
||||
const [submissionTakingLonger, setSubmissionTakingLonger] = useState(false);
|
||||
const submissionRequestId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const confirmed = submissionRequestId.current && comments.some(comment =>
|
||||
comment.clientRequestId === submissionRequestId.current && !("clientStatus" in comment && comment.clientStatus));
|
||||
// The previous intent's projection cannot acknowledge this submission.
|
||||
if (isRunning || confirmed) {
|
||||
setSubmissionPending(false);
|
||||
setSubmissionTakingLonger(false);
|
||||
}
|
||||
}, [isRunning, comments, submissionPending]);
|
||||
useEffect(() => {
|
||||
if (!submissionPending) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setSubmissionTakingLonger(true);
|
||||
void onRefreshLatestComments?.();
|
||||
}, 10_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [submissionPending, onRefreshLatestComments]);
|
||||
const {
|
||||
pending: submissionPending,
|
||||
takingLonger: submissionTakingLonger,
|
||||
begin: beginSubmission,
|
||||
reject: rejectSubmission,
|
||||
blocked: startupBlocked,
|
||||
} = useRunnerSubmission({
|
||||
issueId,
|
||||
agentId: issueAssigneeAgentId,
|
||||
agent: agentMap?.get(issueAssigneeAgentId ?? ""),
|
||||
blocked: Boolean(composerPause),
|
||||
comments,
|
||||
issueStatus,
|
||||
runId: displayLiveRuns[0]?.id ?? null,
|
||||
linkedRuns,
|
||||
interruption,
|
||||
onRefresh: onRefreshLatestComments,
|
||||
});
|
||||
const sendComposerComment = useCallback<IssueChatThreadProps["onAdd"]>(
|
||||
async (body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) => {
|
||||
pendingSubmitScrollRef.current = true;
|
||||
if (issueAssigneeAgentId) {
|
||||
submissionRequestId.current = clientRequestId ?? null;
|
||||
setSubmissionPending(true);
|
||||
setSubmissionTakingLonger(false);
|
||||
}
|
||||
clientRequestId ??= crypto.randomUUID();
|
||||
const attempt = beginSubmission(clientRequestId);
|
||||
try {
|
||||
await (runSettings
|
||||
? onAdd(body, reopen, reassignment, attachmentIds, clientRequestId, runSettings)
|
||||
: onAdd(body, reopen, reassignment, attachmentIds, clientRequestId));
|
||||
setSubmissionPending(false);
|
||||
setSubmissionTakingLonger(false);
|
||||
} catch (error) {
|
||||
setSubmissionPending(error instanceof CommentSubmissionUnknownError);
|
||||
setSubmissionTakingLonger(error instanceof CommentSubmissionUnknownError);
|
||||
rejectSubmission(attempt, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[onAdd, issueAssigneeAgentId],
|
||||
[onAdd, beginSubmission, rejectSubmission],
|
||||
);
|
||||
const runtime = usePaperclipIssueRuntime({
|
||||
messages,
|
||||
@@ -6666,7 +6659,7 @@ export function IssueChatThread({
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{!isRunning && issueAssigneeAgentId && (submissionPending || retryPreparing || interruption?.state === "working") ? (
|
||||
{!isRunning && !startupBlocked && issueAssigneeAgentId && (submissionPending || retryPreparing || interruption?.state === "working") ? (
|
||||
<TaskChatRunnerTurn items={[]} status="queued" startedAtMs={null}
|
||||
agent={agentMap?.get(issueAssigneeAgentId)}
|
||||
agentName={agentMap?.get(issueAssigneeAgentId)?.name}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TaskChatThread } from "./TaskChatThread";
|
||||
import { IssueChatThread } from "./IssueChatThread";
|
||||
@@ -161,7 +162,7 @@ function render(ui: ReactElement) {
|
||||
flushSync(() =>
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>{ui}</ThemeProvider>
|
||||
<ThemeProvider><TooltipProvider>{ui}</TooltipProvider></ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
@@ -459,17 +460,19 @@ it("shows Thinking during admission and clears it when the saved follow-up enter
|
||||
|
||||
it.each((["task", "legacy"] as const).flatMap(surface =>
|
||||
([null, "failed", "resolved"] as const).map(previousState => ({ surface, previousState })),
|
||||
))("keeps $surface admission Thinking through an old $previousState projection until acknowledgement", async ({ surface, previousState }) => {
|
||||
))("keeps $surface admission identity through an old $previousState projection and comment acknowledgement", async ({ surface, previousState }) => {
|
||||
let acknowledge!: () => void;
|
||||
const onAdd = vi.fn(() => new Promise<void>(resolve => { acknowledge = resolve; }));
|
||||
const onAdd = vi.fn<ComponentProps<typeof TaskChatThread>["onAdd"]>(() => new Promise<void>(resolve => { acknowledge = resolve; }));
|
||||
const refresh = vi.fn();
|
||||
const Component = surface === "task" ? TaskChatThread : IssueChatThread;
|
||||
render(<Component comments={[]} issueId="issue" issueAssigneeAgentId="agent"
|
||||
issueStatus="todo" onAdd={onAdd} onRefreshLatestComments={refresh}
|
||||
const props = { comments: [], issueId: "issue", issueAssigneeAgentId: "agent", issueStatus: "todo", onAdd,
|
||||
onRefreshLatestComments: refresh,
|
||||
agentMap: new Map([["agent", { id: "agent", name: "Worker", adapterType: "paperclip_runner" } as Agent]]) };
|
||||
render(<Component {...props}
|
||||
interruption={previousState ? { intentId: "previous-intent", responsibility: "system", state: previousState,
|
||||
nextRetryAt: null, canRetry: previousState === "failed", userAction: null,
|
||||
sourceRunIds: ["previous-run"], observedRunId: "previous-run" } : null}
|
||||
agentMap={new Map([["agent", { id: "agent", name: "Worker", adapterType: "paperclip_runner" } as Agent]])} />);
|
||||
/>);
|
||||
await act(async () => { editorCallbacks.change("Save this note."); });
|
||||
const send = surface === "task"
|
||||
? container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')
|
||||
@@ -480,17 +483,84 @@ it.each((["task", "legacy"] as const).flatMap(surface =>
|
||||
await act(async () => { send!.click(); });
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Thinking");
|
||||
const identity = container.querySelector('[data-testid="task-chat-agent-identity"]');
|
||||
render(<Component {...props} composerDisabledReason="Could not load task pause state." />);
|
||||
expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
|
||||
expect(container.textContent).toContain("Taking longer than usual");
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { acknowledge(); });
|
||||
expect(container.textContent).not.toContain("Thinking");
|
||||
expect(container.textContent).not.toContain("Taking longer than usual");
|
||||
expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity);
|
||||
expect(container.textContent).toContain("Taking longer than usual");
|
||||
render(<Component {...props} interruption={{ intentId: "new-intent", responsibility: "system", state: "retry_scheduled",
|
||||
nextRetryAt: new Date(Date.now() + 30_000).toISOString(), canRetry: true, userAction: null,
|
||||
sourceRunIds: [], observedRunId: null }} />);
|
||||
expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each((["task", "legacy"] as const).flatMap(surface =>
|
||||
[-300_000, 0, 300_000].map(clockOffset => ({ surface, clockOffset })),
|
||||
))("keeps an existing $surface thread's responder mounted with clock offset $clockOffset", async ({ surface, clockOffset }) => {
|
||||
const serverTime = new Date("2026-01-02T00:00:00Z").getTime();
|
||||
vi.spyOn(Date, "now").mockReturnValue(serverTime + clockOffset);
|
||||
const Component = surface === "task" ? TaskChatThread : IssueChatThread;
|
||||
let acknowledge!: () => void;
|
||||
const onAdd = vi.fn<ComponentProps<typeof TaskChatThread>["onAdd"]>(() => new Promise<void>(resolve => { acknowledge = resolve; }));
|
||||
const prior = { id: "prior-reply", companyId: "company", issueId: "issue", authorType: "agent" as const,
|
||||
authorAgentId: "agent", authorUserId: null, body: "Here is the previous answer.", presentation: null, metadata: null,
|
||||
createdAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01") };
|
||||
const props = { issueId: "issue", issueStatus: "in_progress", currentUserId: "board", issueAssigneeAgentId: "agent", onAdd,
|
||||
comments: [prior], agentMap: new Map([["agent", { id: "agent", name: "Worker", adapterType: "paperclip_runner" } as Agent]]) };
|
||||
render(<Component {...props} />);
|
||||
await act(async () => { editorCallbacks.change("Please check again."); });
|
||||
const send = surface === "task" ? container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')
|
||||
: [...container.querySelectorAll<HTMLButtonElement>('[data-testid="issue-chat-composer"] button')].find(button => button.textContent === "Send");
|
||||
await act(async () => { send!.click(); });
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
const identity = container.querySelectorAll('[data-testid="task-chat-agent-identity"]');
|
||||
const responder = identity[identity.length - 1];
|
||||
const avatar = responder?.querySelector("img");
|
||||
expect(responder?.textContent).toBe("Worker");
|
||||
expect(avatar).not.toBeNull();
|
||||
const saved = { ...prior, id: "saved-message", authorType: "user" as const, authorAgentId: null, authorUserId: "board",
|
||||
body: "Please check again.", clientRequestId: onAdd.mock.calls[0]?.[4], createdAt: new Date(serverTime), updatedAt: new Date(serverTime) };
|
||||
render(<Component {...props} comments={[prior, saved]} />);
|
||||
await act(async () => { acknowledge(); });
|
||||
expect(container.contains(responder)).toBe(true);
|
||||
expect(responder.querySelector("img")).toBe(avatar);
|
||||
render(<Component {...props} comments={[prior, saved]} linkedRuns={[{
|
||||
runId: "older-run", agentId: "agent", status: "succeeded", createdAt: "2026-01-01", startedAt: "2026-01-01",
|
||||
}]} />);
|
||||
expect(container.contains(responder)).toBe(true);
|
||||
render(<Component {...props} comments={[prior, saved]} linkedRuns={[{
|
||||
runId: "new-run", agentId: "agent", status: "succeeded", createdAt: new Date(serverTime + 1), startedAt: new Date(serverTime + 1),
|
||||
}]} />);
|
||||
expect(container.contains(responder)).toBe(false);
|
||||
});
|
||||
|
||||
it.each((["task", "legacy"] as const).flatMap(surface => [
|
||||
...(["paused", "pending_approval", "terminated"] as const).map(status => ({ surface, reason: status, status, orgChainHealth: undefined })),
|
||||
...(["missing_manager", "terminated_ancestor", "cycle"] as const).map(reason => ({ surface, reason, status: "idle" as const,
|
||||
orgChainHealth: { status: "invalid_org_chain", reason } as Agent["orgChainHealth"] })),
|
||||
]))("does not show startup work with $reason in $surface chat", async ({ surface, status, orgChainHealth }) => {
|
||||
const Component = surface === "task" ? TaskChatThread : IssueChatThread;
|
||||
const onAdd = vi.fn<ComponentProps<typeof TaskChatThread>["onAdd"]>().mockResolvedValue(undefined);
|
||||
const agentMap = new Map([["agent", { id: "agent", name: "Worker", status, orgChainHealth, adapterType: "paperclip_runner" } as Agent]]);
|
||||
const props = { issueId: "issue", comments: [], issueStatus: "todo", issueAssigneeAgentId: "agent", agentMap, onAdd };
|
||||
render(<Component {...props} />);
|
||||
await act(async () => { editorCallbacks.change("Save a note for later."); });
|
||||
const send = surface === "task" ? container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')
|
||||
: [...container.querySelectorAll<HTMLButtonElement>('[data-testid="issue-chat-composer"] button')].find(button => button.textContent === "Send");
|
||||
await act(async () => { send!.click(); });
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Thinking");
|
||||
expect(container.textContent).not.toContain("Taking longer than usual");
|
||||
});
|
||||
|
||||
it("prepares comment-link history and its composer before reveal", async () => {
|
||||
const props = {
|
||||
issueId: "linked-issue",
|
||||
@@ -1112,7 +1182,7 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
expect(nativeRuns.map((run) => run.id)).toEqual(["native-run"]);
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps the runner identity and activity mounted through startup (mobile=%s)", (mobile) => {
|
||||
it.each([false, true])("keeps the runner identity and activity mounted through startup (mobile=%s)", async (mobile) => {
|
||||
sidebarState.isMobile = mobile;
|
||||
const run = nativeRunningRun();
|
||||
const comment = {
|
||||
@@ -1127,6 +1197,11 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
currentUserId: "board", issueAssigneeAgentId: "agent-1",
|
||||
agentMap: new Map([["agent-1", { id: "agent-1", name: "Runner", adapterType: "paperclip_runner", icon: "code" } as Agent]]),
|
||||
};
|
||||
renderThread({ ...props, comments: [] });
|
||||
await act(async () => { editorCallbacks.change(comment.body); });
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!.click();
|
||||
});
|
||||
renderThread({ ...props, comments: [{ ...comment, clientStatus: "pending" }] });
|
||||
const identity = container.querySelector('[data-testid="task-chat-agent-identity"]');
|
||||
const header = container.querySelector('[data-testid="task-chat-runner-identity-row"]');
|
||||
@@ -1135,6 +1210,11 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
expect(identity?.querySelector("svg, img")).not.toBeNull();
|
||||
expect(activity?.textContent).toBe("Thinking");
|
||||
|
||||
renderThread(props);
|
||||
expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity);
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-identity-row"]')).toBe(header);
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')).toBe(activity);
|
||||
|
||||
for (const startup of [
|
||||
{ ...run, runtimeMode: undefined, status: "queued", startedAt: null },
|
||||
{ ...run, runtimeMode: "legacy" as const, currentStatusMessage: "run started" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TaskChatShimmerScope } from "@/components/task-chat/TaskChatShimmer";
|
||||
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
||||
import { useRunnerSubmission } from "@/hooks/useRunnerSubmission";
|
||||
import {
|
||||
ISSUE_DETAIL_CONTENT_PAINT_MARK,
|
||||
ISSUE_DETAIL_CONTENT_MEASURE,
|
||||
@@ -2647,55 +2647,45 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
);
|
||||
const runtimeComposerDisabledReason = composerDisabledReason ?? undefined;
|
||||
const hasAgentAssignee = Boolean(issueAssigneeAgentId);
|
||||
const [runnerSubmissionPending, setRunnerSubmissionPending] = useState(false);
|
||||
const [submissionTakingLonger, setSubmissionTakingLonger] = useState(false);
|
||||
const submissionRequestId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const confirmed = submissionRequestId.current && comments.some(comment =>
|
||||
comment.clientRequestId === submissionRequestId.current && !("clientStatus" in comment && comment.clientStatus));
|
||||
// The previous intent's projection cannot acknowledge this submission.
|
||||
if (tailRunId || confirmed) {
|
||||
setRunnerSubmissionPending(false);
|
||||
setSubmissionTakingLonger(false);
|
||||
}
|
||||
}, [tailRunId, comments, runnerSubmissionPending]);
|
||||
useEffect(() => {
|
||||
if (!runnerSubmissionPending || tailRunId) return;
|
||||
const timeout = window.setTimeout(
|
||||
() => {
|
||||
setSubmissionTakingLonger(true);
|
||||
void props.onRefreshLatestComments?.();
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [runnerSubmissionPending, tailRunId]);
|
||||
const {
|
||||
pending: runnerSubmissionPending,
|
||||
takingLonger: submissionTakingLonger,
|
||||
begin: beginSubmission,
|
||||
reject: rejectSubmission,
|
||||
blocked: startupBlocked,
|
||||
} = useRunnerSubmission({
|
||||
issueId: issueId ?? null,
|
||||
agentId: issueAssigneeAgentId,
|
||||
agent: agentMap?.get(issueAssigneeAgentId ?? ""),
|
||||
blocked: Boolean(composerPause),
|
||||
comments,
|
||||
issueStatus,
|
||||
runId: liveRun?.id ?? null,
|
||||
linkedRuns,
|
||||
interruption: props.interruption,
|
||||
onRefresh: props.onRefreshLatestComments,
|
||||
});
|
||||
const handleThreadAdd = useCallback(
|
||||
async (...args: Parameters<typeof onAdd>) => {
|
||||
if (hasAgentAssignee) {
|
||||
submissionRequestId.current = args[4] ?? null;
|
||||
setRunnerSubmissionPending(true);
|
||||
setSubmissionTakingLonger(false);
|
||||
}
|
||||
args[4] ??= crypto.randomUUID();
|
||||
const attempt = beginSubmission(args[4]);
|
||||
try {
|
||||
await onAdd(...args);
|
||||
setRunnerSubmissionPending(false);
|
||||
setSubmissionTakingLonger(false);
|
||||
if (selectedPendingInput?.kind === "durable" && selectedPendingInput.interaction.kind === "ask_user_questions") {
|
||||
dismissInput(selectedPendingInput.interaction.id);
|
||||
setSelectedPendingKey(null);
|
||||
setTakeoverMode("normal");
|
||||
}
|
||||
} catch (error) {
|
||||
setRunnerSubmissionPending(error instanceof CommentSubmissionUnknownError);
|
||||
setSubmissionTakingLonger(error instanceof CommentSubmissionUnknownError);
|
||||
rejectSubmission(attempt, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[hasAgentAssignee, onAdd, selectedPendingInput, dismissInput],
|
||||
[beginSubmission, rejectSubmission, onAdd, selectedPendingInput, dismissInput],
|
||||
);
|
||||
const optimisticRunnerStartup =
|
||||
hasAgentAssignee &&
|
||||
!startupBlocked &&
|
||||
!tailRunId &&
|
||||
(props.retryPreparing || props.interruption?.state === "working" || runnerSubmissionPending ||
|
||||
commentItems.some(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { isAgentStatusInvokable, type Agent, type TaskInterruptionPresentation } from "@paperclipai/shared";
|
||||
import type { IssueChatComment, IssueChatLinkedRun } from "@/lib/issue-chat-messages";
|
||||
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
|
||||
import { isTerminalIssueStatus } from "@/lib/liveIssueIds";
|
||||
|
||||
interface Submission {
|
||||
attemptId: number;
|
||||
issueId: string | null;
|
||||
agentId: string;
|
||||
runIds: Set<string>;
|
||||
clientRequestId: string;
|
||||
interruptionKey: string;
|
||||
takingLonger: boolean;
|
||||
}
|
||||
|
||||
export function useRunnerSubmission({
|
||||
issueId,
|
||||
agentId,
|
||||
agent,
|
||||
blocked = false,
|
||||
comments,
|
||||
issueStatus,
|
||||
runId,
|
||||
linkedRuns = [],
|
||||
interruption,
|
||||
onRefresh,
|
||||
}: {
|
||||
issueId: string | null;
|
||||
agentId: string | null;
|
||||
agent?: Partial<Pick<Agent, "status" | "orgChainHealth">>;
|
||||
blocked?: boolean;
|
||||
comments: readonly IssueChatComment[];
|
||||
issueStatus?: string;
|
||||
runId: string | null;
|
||||
linkedRuns?: readonly IssueChatLinkedRun[];
|
||||
interruption?: TaskInterruptionPresentation | null;
|
||||
onRefresh?: () => void | Promise<unknown>;
|
||||
}) {
|
||||
const [submission, setSubmission] = useState<Submission | null>(null);
|
||||
const nextAttemptId = useRef(0);
|
||||
const refreshRef = useRef(onRefresh);
|
||||
refreshRef.current = onRefresh;
|
||||
const interruptionKey = JSON.stringify([
|
||||
interruption?.intentId, interruption?.state, interruption?.observedRunId,
|
||||
]);
|
||||
const startupBlocked = blocked ||
|
||||
(agent?.status !== undefined && !isAgentStatusInvokable(agent.status)) ||
|
||||
agent?.orgChainHealth?.status === "invalid_org_chain";
|
||||
const savedComment = submission && comments.find(comment =>
|
||||
comment.clientRequestId === submission.clientRequestId && !comment.clientStatus);
|
||||
const acknowledgedAt = savedComment ? new Date(savedComment.createdAt).getTime() : null;
|
||||
const submissionRuns = submission && savedComment ? linkedRuns.filter(run =>
|
||||
run.agentId === submission.agentId && (
|
||||
run.runId === savedComment.consumedByRunId || run.runId === savedComment.steeredIntoRunId ||
|
||||
(!submission.runIds.has(run.runId) && acknowledgedAt !== null &&
|
||||
new Date(run.createdAt).getTime() >= acknowledgedAt)
|
||||
)) : [];
|
||||
const settled = Boolean(submission && (
|
||||
runId || startupBlocked || isTerminalIssueStatus(issueStatus) ||
|
||||
submission.agentId !== agentId ||
|
||||
(submission.issueId && submission.issueId !== issueId) ||
|
||||
(interruption && interruption.state !== "working" &&
|
||||
interruptionKey !== submission.interruptionKey &&
|
||||
(interruption.intentId !== null ||
|
||||
submissionRuns.some(run => run.runId === interruption.observedRunId))) ||
|
||||
submissionRuns.some(run =>
|
||||
run.status !== "queued" && run.status !== "running")
|
||||
));
|
||||
const pending = submission !== null && !settled;
|
||||
const takingLonger = submission?.takingLonger ?? false;
|
||||
const attemptId = submission?.attemptId;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (settled) {
|
||||
setSubmission(null);
|
||||
}
|
||||
}, [settled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setSubmission(current => current && current.attemptId === attemptId
|
||||
? { ...current, takingLonger: true } : current);
|
||||
void refreshRef.current?.();
|
||||
}, 10_000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [pending, attemptId]);
|
||||
|
||||
const begin = useCallback((clientRequestId: string) => {
|
||||
const next: Submission | null = agentId && !startupBlocked ? {
|
||||
issueId, agentId, interruptionKey, clientRequestId,
|
||||
attemptId: ++nextAttemptId.current, takingLonger: false,
|
||||
runIds: new Set(linkedRuns.map(run => run.runId)),
|
||||
} : null;
|
||||
setSubmission(next);
|
||||
return next?.attemptId ?? null;
|
||||
}, [agentId, issueId, interruptionKey, linkedRuns, startupBlocked]);
|
||||
|
||||
const reject = useCallback((attempt: number | null, error: unknown) => {
|
||||
setSubmission(current => {
|
||||
if (!current || current.attemptId !== attempt) return current;
|
||||
return error instanceof CommentSubmissionUnknownError
|
||||
? { ...current, takingLonger: true } : null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { pending, takingLonger, begin, reject, blocked: startupBlocked };
|
||||
}
|
||||
Reference in new issue
Block a user