From 841742fc1a08f56bf05d42b008dc548151db024a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:52:36 -0500 Subject: [PATCH] [codex] Graduate experimental conference room defaults (#8628) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI has been graduating experimental conference-room task experiences into the default issue and onboarding flows > - Several task UI improvements were still coupled to the Conference Room Chat experimental flag even though they are useful outside chat itself > - That coupling meant disabling chat also reverted unrelated defaults such as work-mode labels, task status colors, team creation copy, and the graduated issue thread > - This pull request keeps chat-specific gating scoped to chat while making the graduated task UI the default experience > - The benefit is that operators can use the newer task workflows without needing to enable the separate chat experiment ## Linked Issues or Issue Description No public GitHub issue exists for this exact change. ### Problem or motivation The Conference Room Chat experimental flag was controlling unrelated task UI defaults, which made non-chat workflows regress when chat was disabled. ### Proposed solution Remove that flag from task-thread, work-mode, onboarding, status, and team-creation presentation paths while leaving chat-specific behavior separately gated. ### Alternatives considered Keeping the flag as a broad umbrella until chat graduates would avoid a behavior change, but it keeps unrelated UI improvements hidden behind the wrong capability switch. ### Roadmap alignment Checked `ROADMAP.md`; this is focused graduation/polish for existing UI surfaces rather than a new roadmap-level core feature. Related search: - Searched open PRs and issues for `conference room chat experimental flag`: no matches. - Searched open PRs and issues for `graduated issue thread`: no matches. ## What Changed - Removes Conference Room Chat flag branching from task-thread rendering, work-mode labels, task status colors, and team creation copy. - Makes the onboarding completion path create/reuse an onboarding project, create the first assigned task, and send the user to the dashboard instead of chat. - Deletes the legacy onboarding wizard and classic task-thread files now that the graduated flow is the default. - Updates focused UI tests and affected E2E specs for the default task experience. - Adds a user-visible onboarding error if restored state is missing the company or agent required for launch. ## Verification - `pnpm run preflight:workspace-links && pnpm exec vitest run ui/src/components/NewIssueDialog.test.tsx ui/src/components/OnboardingWizardVariant.test.tsx ui/src/components/RunChatSurface.test.tsx ui/src/components/SidebarCompanyMenu.test.tsx ui/src/components/StatusBadge.test.tsx ui/src/lib/agent-order.test.ts ui/src/lib/onboarding-launch.test.ts ui/src/lib/work-mode-meta.test.ts ui/src/pages/IssueDetail.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - `npx playwright test --config tests/e2e/playwright.config.ts --list tests/e2e/conference-room-typing-intro.spec.ts tests/e2e/planning-mode-visual-verification.spec.ts` - Attempted targeted Playwright execution locally, but this host is missing Chromium system libraries (`libatk1.0-0t64`, `libatspi2.0-0t64`, `libxcomposite1`, `libxdamage1`, `libxfixes3`, `libxrandr2`, `libgbm1`, `libasound2t64`). CI runs the specs in the proper Actions environment. ## Risks - Medium UI behavior risk: this intentionally changes the default experience for users who have not enabled Conference Room Chat. - Medium onboarding risk: completion now creates/reuses a project and creates the first task instead of only navigating. - Low migration risk: no database schema or migration changes are included. - The PR avoids `pnpm-lock.yaml` and `.github/workflows` changes. ## Model Used OpenAI Codex, GPT-5-class coding model, tool-enabled local repository workflow with shell, git, and test execution. ## 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 - [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 --- .../e2e/conference-room-typing-intro.spec.ts | 79 +- .../planning-mode-visual-verification.spec.ts | 122 +- ui/src/components/IssueChatThread.tsx | 8 +- ui/src/components/IssueChatThreadClassic.tsx | 4600 ----------------- .../IssueThreadInteractionCardClassic.tsx | 1890 ------- ui/src/components/NewIssueDialog.test.tsx | 29 +- ui/src/components/NewIssueDialog.tsx | 21 +- ui/src/components/OnboardingWizard.tsx | 124 +- ui/src/components/OnboardingWizardClassic.tsx | 1339 ----- .../OnboardingWizardVariant.test.tsx | 59 +- ui/src/components/OnboardingWizardVariant.tsx | 18 +- ui/src/components/RunChatSurface.test.tsx | 41 +- ui/src/components/RunChatSurface.tsx | 9 +- ui/src/components/SidebarCompanyMenu.test.tsx | 63 +- ui/src/components/SidebarCompanyMenu.tsx | 6 +- ui/src/components/StatusBadge.test.tsx | 33 +- ui/src/components/StatusBadge.tsx | 10 +- ui/src/hooks/useAgentOrder.ts | 8 +- ui/src/lib/agent-order.test.ts | 2 +- ui/src/lib/agent-order.ts | 4 +- ui/src/lib/onboarding-launch.test.ts | 17 + ui/src/lib/onboarding-launch.ts | 14 +- ui/src/lib/status-colors.ts | 29 +- ui/src/lib/work-mode-meta.test.ts | 16 +- ui/src/lib/work-mode-meta.ts | 26 +- ui/src/pages/IssueDetail.test.tsx | 70 +- ui/src/pages/IssueDetail.tsx | 23 +- 27 files changed, 333 insertions(+), 8327 deletions(-) delete mode 100644 ui/src/components/IssueChatThreadClassic.tsx delete mode 100644 ui/src/components/IssueThreadInteractionCardClassic.tsx delete mode 100644 ui/src/components/OnboardingWizardClassic.tsx diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts index b4726401be..0a3bd134ab 100644 --- a/tests/e2e/conference-room-typing-intro.spec.ts +++ b/tests/e2e/conference-room-typing-intro.spec.ts @@ -1,35 +1,22 @@ import { test, expect } from "@playwright/test"; /** - * E2E: post-wizard Conference Room typing intro (PAP-134, plan PAP-133). + * E2E: post-wizard onboarding launch. * - * Completing the onboarding wizard must land the user in the Conference Room - * with a staged intro: three-dot typing bubble first (~2s), then the CEO - * welcome message, then the suggestion chips. This is the regression spec - * from the PAP-133 investigation, checked in so the intro can't silently - * vanish again (it already did once — PAP-54 dropped the dots CSS during a - * theme migration). - * - * The wizard is driven end-to-end against real endpoints with two - * deterministic intercepts so no live LLM/CLI is needed: - * - the adapter env-test returns an instant pass, and - * - the team-lead hire is re-issued server-side as a REAL hire with an - * inert `http` adapter (dead URL, heartbeat disabled), so a real CEO - * agent exists for the welcome bubble but no agent process ever runs. + * Completing the onboarding wizard now creates the first assigned task and + * lands the user on the company dashboard. The chat intro still has unit + * coverage in BoardChat tests; the wizard handoff no longer routes there. */ const COMPANY_NAME = `E2E-TypingIntro-${Date.now()}`; -const MISSION = "Verify the typing-dots intro survives the wizard handoff."; +const MISSION = "Verify the dashboard launch survives the wizard handoff."; +const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan"; -test.describe("Conference Room typing intro after onboarding wizard", () => { - test("shows typing dots first, then welcome, then chips", async ({ +test.describe("Dashboard launch after onboarding wizard", () => { + test("creates the first task and opens the dashboard", async ({ page, baseURL, }) => { - // The dots animation is intentionally disabled under reduced motion — - // pin the e2e run to full motion so the animation guard is deterministic. - await page.emulateMedia({ reducedMotion: "no-preference" }); - // Intercept env-test → instant pass (avoid running a real CLI check). await page.route("**/test-environment", (route) => route.fulfill({ @@ -65,13 +52,6 @@ test.describe("Conference Room typing intro after onboarding wizard", () => { }); }); - // New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the - // experimental flag on for this throwaway instance before driving them. - const flagRes = await page.request.patch("/api/instance/settings/experimental", { - data: { enableConferenceRoomChat: true }, - }); - expect(flagRes.ok()).toBe(true); - await page.goto("/onboarding"); // Launcher card path (existing companies) — enter the wizard if the @@ -102,41 +82,24 @@ test.describe("Conference Room typing intro after onboarding wizard", () => { // Step 4: adapter (claude_local default); heartbeat is intercepted. await page.getByRole("button", { name: /Give it a heartbeat/ }).click(); - // Step 5: review → Get started hands off to the Conference Room. + // Step 5: review → Get started creates the first task and opens dashboard. const getStarted = page.getByRole("button", { name: /Get started/ }); await getStarted.waitFor({ timeout: 20_000 }); await getStarted.click(); - // Dots-first: the typing bubble must be on screen before the welcome. - const dots = page.locator(".typing-dots"); - await expect(dots).toBeVisible({ timeout: 5_000 }); + await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 }); - // Atomic snapshot — dots and welcome state read in one evaluation so a - // slow assertion can't race the 2s reveal timer. - const snapshot = await page.evaluate(() => ({ - dots: document.querySelectorAll(".typing-dots").length, - welcomeVisible: document.body.textContent?.includes("Welcome to") ?? false, - })); - expect(snapshot.dots).toBeGreaterThan(0); - expect(snapshot.welcomeVisible).toBe(false); + const companiesRes = await page.request.get("/api/companies"); + expect(companiesRes.ok()).toBe(true); + const companies = await companiesRes.json(); + const company = companies.find((candidate: { name: string }) => candidate.name === COMPANY_NAME); + expect(company).toBeTruthy(); - // Animation-presence guard (PAP-54 failure mode): the dots must carry a - // real computed animation, not silently render as static circles after - // the CSS block gets dropped in a refactor. - const animationName = await dots - .locator("span") - .first() - .evaluate((el) => getComputedStyle(el).animationName); - expect(animationName).not.toBe("none"); - expect(animationName).toBeTruthy(); - - // Staged reveal completes: welcome bubble (~2s) then chips (~+700ms). - await expect(page.getByText(/Welcome to/).first()).toBeVisible({ - timeout: 10_000, - }); - await expect( - page.getByRole("button", { name: "Draft a Company Brief" }), - ).toBeVisible({ timeout: 5_000 }); - await expect(dots).toHaveCount(0); + const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`); + expect(issuesRes.ok()).toBe(true); + const issues = await issuesRes.json(); + const firstTask = issues.find((candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE); + expect(firstTask).toBeTruthy(); + await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 }); }); }); diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index aa3c557ac6..84026eb9f2 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -1,80 +1,74 @@ import { expect, test } from "@playwright/test"; -const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false"; - -const AGENT_NAME = "CEO"; -const TASK_TITLE = "PAP-3413 planning mode evidence"; +const AGENT_NAME = "Chief of staff"; +const TASK_TITLE = "Hire your first engineer and create a hiring plan"; test("captures planning mode UI for desktop and mobile", async ({ page }) => { const timestamp = Date.now(); const companyName = `PAP-3413-${timestamp}`; const screenshotDir = "test-results/planning-mode"; - // This spec captures the CLASSIC (flag-off) wizard + composer; pin the - // experimental flag off in case an earlier spec on this shared instance - // turned it on (the NUX specs do). - const flagRes = await page.request.patch("/api/instance/settings/experimental", { - data: { enableConferenceRoomChat: false }, + await page.route("**/test-environment", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ status: "pass", checks: [] }), + }), + ); + + await page.route("**/agent-hires", async (route) => { + const req = route.request(); + const body = JSON.parse(req.postData() || "{}"); + const auth = req.headers().authorization; + const real = await fetch(new URL(req.url()).toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(auth ? { Authorization: auth } : {}), + }, + body: JSON.stringify({ + name: body.name, + role: body.role, + adapterType: "http", + adapterConfig: { url: "http://127.0.0.1:1/dead" }, + runtimeConfig: { heartbeat: { enabled: false } }, + }), + }); + await route.fulfill({ + status: real.status, + contentType: "application/json", + body: await real.text(), + }); }); - expect(flagRes.ok()).toBe(true); await page.goto("/onboarding"); - await expect(page.locator("h3", { hasText: "Name your company" })).toBeVisible({ timeout: 5_000 }); + const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ }); + if (await startBtn.count()) await startBtn.first().click(); + + const createCard = page.getByRole("button", { name: /Build a new team/ }); + if (await createCard.count()) await createCard.first().click(); + + await expect(page.getByRole("heading", { name: "Name your team" })).toBeVisible({ timeout: 15_000 }); await page.locator('input[placeholder="Acme Corp"]').fill(companyName); - await page.getByRole("button", { name: "Next" }).click(); + await page.getByRole("button", { name: /^Next/ }).click(); - await expect(page.locator("h3", { hasText: "Create your first agent" })).toBeVisible({ timeout: 30_000 }); - await expect(page.locator('input[placeholder="CEO"]')).toHaveValue(AGENT_NAME); - await page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByRole("heading", { name: "Define your mission" })).toBeVisible({ timeout: 30_000 }); + await page + .getByPlaceholder("What is your team trying to achieve?") + .fill("Capture planning mode visual evidence for the graduated task UI."); + await page.getByRole("button", { name: /Confirm mission/ }).click(); - await expect(page.locator("h3", { hasText: "Give it something to do" })).toBeVisible({ timeout: 30_000 }); - const baseUrl = page.url().split("/").slice(0, 3).join("/"); + await page.waitForSelector('input[placeholder="Chief of staff"]', { timeout: 30_000 }); + await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME); - if (SKIP_LLM) { - const companiesAfterAgentRes = await page.request.get(`${baseUrl}/api/companies`); - expect(companiesAfterAgentRes.ok()).toBe(true); - const companiesAfterAgent = await companiesAfterAgentRes.json(); - const companyAfterAgent = companiesAfterAgent.find((c: { name: string }) => c.name === companyName); - expect(companyAfterAgent).toBeTruthy(); + await page.getByRole("button", { name: /^Next/ }).click(); + await page.getByRole("button", { name: /Give it a heartbeat/ }).click(); - const agentsAfterCreateRes = await page.request.get(`${baseUrl}/api/companies/${companyAfterAgent.id}/agents`); - expect(agentsAfterCreateRes.ok()).toBe(true); - const agentsAfterCreate = await agentsAfterCreateRes.json(); - const ceoAgentAfterCreate = agentsAfterCreate.find((a: { name: string }) => a.name === AGENT_NAME); - expect(ceoAgentAfterCreate).toBeTruthy(); + await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 }); + await page.getByRole("button", { name: /Get started/ }).click(); + await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 }); - const disableWakeRes = await page.request.patch( - `${baseUrl}/api/agents/${ceoAgentAfterCreate.id}?companyId=${encodeURIComponent(companyAfterAgent.id)}`, - { - data: { - runtimeConfig: { - heartbeat: { - enabled: false, - intervalSec: 300, - wakeOnDemand: false, - cooldownSec: 10, - maxConcurrentRuns: 5, - }, - }, - }, - }, - ); - expect(disableWakeRes.ok()).toBe(true); - } - - const taskTitleInput = page.locator('input[placeholder="e.g. Research competitor pricing"]'); - await taskTitleInput.clear(); - await taskTitleInput.fill(TASK_TITLE); - await page.getByRole("button", { name: "Next" }).click(); - - await expect(page.locator("h3", { hasText: "Ready to launch" })).toBeVisible({ timeout: 30_000 }); - await page.getByRole("button", { name: "Create & Open Task" }).click(); - await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 }); - - const openedIssueUrl = page.url(); - const openedIssueIdentifier = openedIssueUrl.split("/").filter(Boolean).pop(); - const baseOrigin = new URL(openedIssueUrl).origin; + const baseOrigin = new URL(page.url()).origin; const companyRes = await page.request.get(`${baseOrigin}/api/companies`); expect(companyRes.ok()).toBe(true); const companies = await companyRes.json(); @@ -85,7 +79,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { const issues = await issueRes.json(); const planningSeedIssue = issues.find( (candidate: { id: string; identifier?: string; title: string }) => - candidate.identifier === openedIssueIdentifier || candidate.id === openedIssueIdentifier || candidate.title === TASK_TITLE, + candidate.title === TASK_TITLE, ); expect(planningSeedIssue).toBeTruthy(); @@ -113,7 +107,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await setMode("planning"); await page.goto(issuePath); - await expect(page.getByText("Planning").first()).toBeVisible(); + await expect(page.getByText("Plan mode").first()).toBeVisible(); await expect(page.getByTestId("issue-chat-composer")).toHaveAttribute("data-pending-work-mode", "planning"); const desktopPlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle"); await expect(desktopPlanningToggle).toBeVisible(); @@ -127,7 +121,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await page.goto(`/${companyPrefix}/issues`); await expect(page.locator(issueLinkSelector)).toBeVisible(); - await expect(page.locator(issueLinkSelector)).not.toContainText("Planning"); + await expect(page.locator(issueLinkSelector)).not.toContainText("Plan mode"); await page.screenshot({ path: `${screenshotDir}/desktop-planning-row-${timestamp}.png`, fullPage: true, @@ -147,7 +141,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await setMode("planning"); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(issuePath); - await expect(page.getByText("Planning").first()).toBeVisible(); + await expect(page.getByText("Plan mode").first()).toBeVisible(); const mobilePlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle"); await expect(mobilePlanningToggle).toBeVisible(); await expect(mobilePlanningToggle).toHaveAttribute("data-pending-work-mode", "planning"); @@ -159,7 +153,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await page.goto(`/${companyPrefix}/issues`); await expect(page.locator(issueLinkSelector)).toBeVisible(); - await expect(page.locator(issueLinkSelector)).not.toContainText("Planning"); + await expect(page.locator(issueLinkSelector)).not.toContainText("Plan mode"); await page.screenshot({ path: `${screenshotDir}/mobile-planning-row-${timestamp}.png`, fullPage: true, diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index f4887e2ba0..7e3d651940 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -3796,8 +3796,8 @@ const IssueChatComposer = forwardRef) { @@ -3809,7 +3809,7 @@ const IssueChatComposer = forwardRef nextWorkMode(current, true)); + setPendingWorkMode((current) => nextWorkMode(current)); } return ( @@ -3962,7 +3962,7 @@ const IssueChatComposer = forwardRef; - currentUserId?: string | null; - userLabelMap?: ReadonlyMap | null; - userProfileMap?: ReadonlyMap | null; - onVote?: ( - commentId: string, - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => Promise; - onStopRun?: (runId: string) => Promise; - stopRunLabel?: string; - stoppingRunLabel?: string; - stopRunVariant?: "stop" | "pause"; - runFinalizationActions?: readonly IssueChatRunFinalizationAction[]; - onInterruptQueued?: (runId: string) => Promise; - onCancelQueued?: (commentId: string) => void; - onDeleteComment?: (commentId: string) => Promise | void; - onImageClick?: (src: string) => void; - onAcceptInteraction?: ( - interaction: - | SuggestTasksInteraction - | RequestConfirmationInteraction - | RequestCheckboxConfirmationInteraction, - selectedClientKeys?: string[], - selectedOptionIds?: string[], - ) => Promise | void; - onRejectInteraction?: ( - interaction: - | SuggestTasksInteraction - | RequestConfirmationInteraction - | RequestCheckboxConfirmationInteraction, - reason?: string, - ) => Promise | void; - onSubmitInteractionAnswers?: ( - interaction: AskUserQuestionsInteraction, - answers: AskUserQuestionsAnswer[], - ) => Promise | void; - onCancelInteraction?: ( - interaction: AskUserQuestionsInteraction, - ) => Promise | void; - issueStatus?: string; - successfulRunHandoff?: SuccessfulRunHandoffState | null; -} - -const IssueChatCtx = createContext({ - feedbackDataSharingPreference: "prompt", - feedbackTermsUrl: null, - issueStatus: undefined, - successfulRunHandoff: null, -}); - -export type IssueChatRunFinalizationAction = { - id: "cancel" | "done"; - label: string; - pendingLabel: string; - onSelect: (runId: string) => Promise | void; - isPending?: boolean; - disabled?: boolean; -}; - -export function resolveAssistantMessageFoldedState(args: { - messageId: string; - currentFolded: boolean; - isFoldable: boolean; - previousMessageId: string | null; - previousIsFoldable: boolean; -}) { - const { - messageId, - currentFolded, - isFoldable, - previousMessageId, - previousIsFoldable, - } = args; - - if (messageId !== previousMessageId) return isFoldable; - if (!isFoldable) return false; - if (!previousIsFoldable) return true; - return currentFolded; -} - -export function canStopIssueChatRun(args: { - runId: string | null; - runStatus: string | null; - activeRunIds: ReadonlySet; -}) { - const { runId, runStatus, activeRunIds } = args; - if (!runId) return false; - if (activeRunIds.has(runId)) return true; - return runStatus === "queued" || runStatus === "running"; -} - -function findCoTSegmentIndex( - messageParts: ReadonlyArray<{ type: string }>, - cotParts: ReadonlyArray<{ type: string }>, -): number { - if (cotParts.length === 0) return -1; - const firstPart = cotParts[0]; - let segIdx = -1; - let inCoT = false; - for (const part of messageParts) { - if (part.type === "reasoning" || part.type === "tool-call") { - if (!inCoT) { segIdx++; inCoT = true; } - if (part === firstPart) return segIdx; - } else { - inCoT = false; - } - } - return -1; -} - -function useLiveElapsed(startMs: number | null | undefined, active: boolean): string | null { - const [, rerender] = useState(0); - useEffect(() => { - if (!active || !startMs) return; - const interval = setInterval(() => rerender((n) => n + 1), 1000); - return () => clearInterval(interval); - }, [active, startMs]); - if (!active || !startMs) return null; - return formatDurationWords(Date.now() - startMs); -} - -function useStableEvent unknown>(callback: T | undefined): T | undefined { - const callbackRef = useRef(callback); - useLayoutEffect(() => { - callbackRef.current = callback; - }, [callback]); - - return useMemo(() => { - if (!callback) return undefined; - return ((...args: Parameters) => callbackRef.current?.(...args)) as T; - // Keep the wrapper stable while the callback identity changes; the ref above - // carries the current callback implementation. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [Boolean(callback)]); -} - -interface CommentReassignment { - assigneeAgentId: string | null; - assigneeUserId: string | null; -} - -export interface IssueChatComposerHandle { - focus: () => void; - restoreDraft: (submittedBody: string) => void; -} - -interface IssueChatComposerProps { - onImageUpload?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; - draftKey?: string; - enableReassign?: boolean; - reassignOptions?: InlineEntityOption[]; - currentAssigneeValue?: string; - suggestedAssigneeValue?: string; - mentions?: MentionOption[]; - agentMap?: Map; - composerDisabledReason?: string | null; - composerHint?: string | null; - issueStatus?: string; - issueWorkMode?: IssueWorkMode; - onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; -} - -interface IssueChatThreadProps { - comments: IssueChatComment[]; - interactions?: IssueThreadInteraction[]; - feedbackVotes?: FeedbackVote[]; - feedbackDataSharingPreference?: FeedbackDataSharingPreference; - feedbackTermsUrl?: string | null; - linkedRuns?: IssueChatLinkedRun[]; - timelineEvents?: IssueTimelineEvent[]; - liveRuns?: LiveRunForIssue[]; - activeRun?: ActiveRunForIssue | null; - issueId?: string | null; - blockedBy?: IssueRelationIssueSummary[]; - blockerAttention?: IssueBlockerAttention | null; - successfulRunHandoff?: SuccessfulRunHandoffState | null; - scheduledRetry?: IssueScheduledRetry | null; - recoveryAction?: IssueRecoveryAction | null; - onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void; - canFalsePositiveRecoveryAction?: boolean; - legacyRecoverySourceIssue?: { - identifier: string | null; - href: string; - title?: string | null; - } | null; - assigneeUserId?: string | null; - onResumeFromBacklog?: () => Promise | void; - resumeFromBacklogPending?: boolean; - companyId?: string | null; - projectId?: string | null; - issueStatus?: string; - agentMap?: Map; - currentUserId?: string | null; - userLabelMap?: ReadonlyMap | null; - userProfileMap?: ReadonlyMap | null; - onVote?: ( - commentId: string, - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => Promise; - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; - onCancelRun?: () => Promise; - onStopRun?: (runId: string) => Promise; - stopRunLabel?: string; - stoppingRunLabel?: string; - stopRunVariant?: "stop" | "pause"; - runFinalizationActions?: readonly IssueChatRunFinalizationAction[]; - imageUploadHandler?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; - draftKey?: string; - enableReassign?: boolean; - reassignOptions?: InlineEntityOption[]; - currentAssigneeValue?: string; - suggestedAssigneeValue?: string; - mentions?: MentionOption[]; - composerDisabledReason?: string | null; - composerHint?: string | null; - onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; - showComposer?: boolean; - showJumpToLatest?: boolean; - emptyMessage?: string; - footer?: ReactNode; - variant?: "full" | "embedded"; - enableLiveTranscriptPolling?: boolean; - transcriptsByRunId?: ReadonlyMap; - hasOutputForRun?: (runId: string) => boolean; - includeSucceededRunsWithoutOutput?: boolean; - onInterruptQueued?: (runId: string) => Promise; - onCancelQueued?: (commentId: string) => void; - onDeleteComment?: (commentId: string) => Promise | void; - interruptingQueuedRunId?: string | null; - stoppingRunId?: string | null; - onImageClick?: (src: string) => void; - onAcceptInteraction?: ( - interaction: - | SuggestTasksInteraction - | RequestConfirmationInteraction - | RequestCheckboxConfirmationInteraction, - selectedClientKeys?: string[], - selectedOptionIds?: string[], - ) => Promise | void; - onRejectInteraction?: ( - interaction: - | SuggestTasksInteraction - | RequestConfirmationInteraction - | RequestCheckboxConfirmationInteraction, - reason?: string, - ) => Promise | void; - onSubmitInteractionAnswers?: ( - interaction: AskUserQuestionsInteraction, - answers: AskUserQuestionsAnswer[], - ) => Promise | void; - onCancelInteraction?: ( - interaction: AskUserQuestionsInteraction, - ) => Promise | void; - composerRef?: Ref; - issueWorkMode?: IssueWorkMode; - /** - * Hook for the parent to refetch comments when the user explicitly asks - * to jump to the latest comment. Used to make sure the absolute newest - * comment is in the loaded set before we scroll to it. - */ - onRefreshLatestComments?: () => Promise | void; -} - -type IssueChatErrorBoundaryProps = { - resetKey: string; - messages: readonly ThreadMessage[]; - emptyMessage: string; - variant: "full" | "embedded"; - children: ReactNode; -}; - -type IssueChatErrorBoundaryState = { - hasError: boolean; -}; - -class IssueChatErrorBoundary extends Component { - override state: IssueChatErrorBoundaryState = { hasError: false }; - - static getDerivedStateFromError(): IssueChatErrorBoundaryState { - return { hasError: true }; - } - - override componentDidCatch(error: unknown, info: ErrorInfo): void { - console.error("Issue chat renderer failed; falling back to safe transcript view", { - error, - info: info.componentStack, - }); - } - - override componentDidUpdate(prevProps: IssueChatErrorBoundaryProps): void { - if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) { - this.setState({ hasError: false }); - } - } - - override render() { - if (this.state.hasError) { - return ( - - ); - } - return this.props.children; - } -} - -function IssueAssigneePausedNotice({ agent }: { agent: Agent | null }) { - if (!agent || agent.status !== "paused") return null; - - const pauseDetail = - agent.pauseReason === "budget" - ? "It was paused by a budget hard stop." - : agent.pauseReason === "system" - ? "It was paused by the system." - : "It was paused manually."; - - return ( -
-
- -

- {agent.name} is paused. New runs will not start until the agent is resumed. {pauseDetail} -

-
-
- ); -} - -function fallbackAuthorLabel(message: ThreadMessage) { - const custom = message.metadata?.custom as Record | undefined; - if (typeof custom?.["authorName"] === "string") return custom["authorName"]; - if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"]; - if (message.role === "assistant") return "Agent"; - if (message.role === "user") return "You"; - return "System"; -} - -function fallbackTextParts(message: ThreadMessage) { - const contentLines: string[] = []; - for (const part of message.content) { - if (part.type === "text" || part.type === "reasoning") { - if (part.text.trim().length > 0) contentLines.push(part.text); - continue; - } - if (part.type === "tool-call") { - const lines = [`Tool: ${part.toolName}`]; - if (part.argsText?.trim()) lines.push(`Args:\n${part.argsText}`); - if (typeof part.result === "string" && part.result.trim()) lines.push(`Result:\n${part.result}`); - contentLines.push(lines.join("\n\n")); - } - } - - const custom = message.metadata?.custom as Record | undefined; - if (contentLines.length === 0 && typeof custom?.["waitingText"] === "string" && custom["waitingText"].trim()) { - contentLines.push(custom["waitingText"]); - } - return contentLines; -} - -function IssueChatFallbackThread({ - messages, - emptyMessage, - variant, -}: { - messages: readonly ThreadMessage[]; - emptyMessage: string; - variant: "full" | "embedded"; -}) { - return ( -
-
-
- -
-

Chat renderer hit an internal state error.

-

- Showing a safe fallback transcript instead of crashing the tasks page. -

-
-
-
- - {messages.length === 0 ? ( -
- {emptyMessage} -
- ) : ( -
- {messages.map((message) => { - const lines = fallbackTextParts(message); - return ( -
-
- {fallbackAuthorLabel(message)} - {message.createdAt ? ( - - {commentDateLabel(message.createdAt)} - - ) : null} -
-
- {lines.length > 0 ? lines.map((line, index) => ( - {line} - )) : ( -

No message content.

- )} -
-
- ); - })} -
- )} -
- ); -} - -const DRAFT_DEBOUNCE_MS = 800; -const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96; -const SUBMIT_SCROLL_RESERVE_VH = 0.4; - -type ComposerAttachmentItem = { - id: string; - name: string; - size: number; - status: "uploading" | "attached" | "error"; - inline: boolean; - contentPath?: string; - error?: string; -}; - -function hasFilePayload(evt: ReactDragEvent) { - return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); -} - -function formatAttachmentSize(bytes: number) { - if (!Number.isFinite(bytes) || bytes <= 0) return ""; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function toIsoString(value: string | Date | null | undefined): string | null { - if (!value) return null; - return typeof value === "string" ? value : value.toISOString(); -} - -function loadDraft(draftKey: string): string { - try { - return localStorage.getItem(draftKey) ?? ""; - } catch { - return ""; - } -} - -function saveDraft(draftKey: string, value: string) { - try { - if (value.trim()) { - localStorage.setItem(draftKey, value); - } else { - localStorage.removeItem(draftKey); - } - } catch { - // Ignore localStorage failures. - } -} - -function clearDraft(draftKey: string) { - try { - localStorage.removeItem(draftKey); - } catch { - // Ignore localStorage failures. - } -} - -function parseReassignment(target: string): PaperclipIssueRuntimeReassignment | null { - if (!target || target === "__none__") { - return { assigneeAgentId: null, assigneeUserId: null }; - } - if (target.startsWith("agent:")) { - const assigneeAgentId = target.slice("agent:".length); - return assigneeAgentId ? { assigneeAgentId, assigneeUserId: null } : null; - } - if (target.startsWith("user:")) { - const assigneeUserId = target.slice("user:".length); - return assigneeUserId ? { assigneeAgentId: null, assigneeUserId } : null; - } - return null; -} - -function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { - const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked"; - return resumesToTodo && assigneeValue.startsWith("agent:"); -} - -function isUnassignedReassignValue(value: string): boolean { - return !value || value === "__none__"; -} - -const WEEK_MS = 7 * 24 * 60 * 60 * 1000; - -function commentDateLabel(date: Date | string | undefined): string { - if (!date) return ""; - const then = new Date(date).getTime(); - if (Date.now() - then < WEEK_MS) return timeAgo(date); - return formatShortDate(date); -} - -const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { - const { onImageClick } = useContext(IssueChatCtx); - if (isSuccessfulRunHandoffComment(text)) { - return ; - } - return ( - - {text} - - ); -}); - -export function SuccessfulRunHandoffCommentCallout({ - text, - recessed, - onImageClick, -}: { - text: string; - recessed?: boolean; - onImageClick?: (src: string) => void; -}) { - const escalated = isSuccessfulRunHandoffEscalationComment(text); - return ( -
-
- - - {text} - -
-
- ); -} - -function humanizeValue(value: string | null) { - if (!value) return "None"; - return value.replace(/_/g, " "); -} - -function formatTimelineAssigneeLabel( - assignee: IssueTimelineAssignee, - agentMap?: Map, - currentUserId?: string | null, - userLabelMap?: ReadonlyMap | null, -) { - if (assignee.agentId) { - return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8); - } - if (assignee.userId) { - return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board"; - } - return "Unassigned"; -} - -function initialsForName(name: string) { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) { - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); - } - return name.slice(0, 2).toUpperCase(); -} - -function formatInteractionActorLabel(args: { - agentId?: string | null; - userId?: string | null; - agentMap?: Map; - currentUserId?: string | null; - userLabelMap?: ReadonlyMap | null; -}) { - const { agentId, userId, agentMap, currentUserId, userLabelMap } = args; - if (agentId) return agentMap?.get(agentId)?.name ?? agentId.slice(0, 8); - if (userId) { - return userLabelMap?.get(userId) - ?? formatAssigneeUserLabel(userId, currentUserId, userLabelMap) - ?? "Board"; - } - return "System"; -} - -export function resolveIssueChatHumanAuthor(args: { - authorName?: string | null; - authorUserId?: string | null; - currentUserId?: string | null; - userProfileMap?: ReadonlyMap | null; -}) { - const { authorName, authorUserId, currentUserId, userProfileMap } = args; - const profile = authorUserId ? userProfileMap?.get(authorUserId) ?? null : null; - const isCurrentUser = Boolean(authorUserId && currentUserId && authorUserId === currentUserId); - const resolvedAuthorName = profile?.label?.trim() - || authorName?.trim() - || (authorUserId === "local-board" ? "Board" : (isCurrentUser ? "You" : "User")); - - return { - isCurrentUser, - authorName: resolvedAuthorName, - avatarUrl: profile?.image ?? null, - }; -} - -function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null { - if (toolParts.length === 0) return null; - let commands = 0; - let other = 0; - for (const tool of toolParts) { - if (isCommandTool(tool.toolName, tool.args)) commands++; - else other++; - } - const parts: string[] = []; - if (commands > 0) parts.push(`ran ${commands} command${commands === 1 ? "" : "s"}`); - if (other > 0) parts.push(`called ${other} tool${other === 1 ? "" : "s"}`); - return parts.join(", "); -} - -function cleanToolDisplayText(tool: ToolCallMessagePart): string { - const name = displayToolName(tool.toolName, tool.args); - if (isCommandTool(tool.toolName, tool.args)) return name; - const summary = tool.result === undefined - ? summarizeToolInput(tool.toolName, tool.args) - : null; - return summary ? `${name} ${summary}` : name; -} - -type IssueChatCoTPart = ReasoningMessagePart | ToolCallMessagePart; - -function IssueChatChainOfThought({ - message, - cotParts, -}: { - message: ThreadMessage; - cotParts: readonly IssueChatCoTPart[]; -}) { - const { agentMap } = useContext(IssueChatCtx); - const custom = message.metadata.custom as Record; - const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; - const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null; - const agentId = authorAgentId ?? runAgentId; - const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined; - const isMessageRunning = message.role === "assistant" && message.status?.type === "running"; - - const myIndex = useMemo( - () => findCoTSegmentIndex(message.content, cotParts), - [message.content, cotParts], - ); - - const allReasoningText = cotParts - .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning" && !!p.text) - .map((p) => p.text) - .join("\n"); - const toolParts = cotParts.filter( - (p): p is ToolCallMessagePart => p.type === "tool-call", - ); - - const rawSegments = Array.isArray(custom.chainOfThoughtSegments) - ? (custom.chainOfThoughtSegments as SegmentTiming[]) - : []; - const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null; - const isActive = isCoTSegmentActive({ - isMessageRunning, - segmentIndex: myIndex, - segmentCount: rawSegments.length, - }); - const [expanded, setExpanded] = useState(isActive); - const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive); - - useEffect(() => { - if (isActive) setExpanded(true); - }, [isActive]); - - let headerVerb: string; - let headerSuffix: string | null = null; - if (isActive) { - headerVerb = "Working"; - if (liveElapsed) headerSuffix = `for ${liveElapsed}`; - } else if (segmentTiming) { - const durationMs = segmentTiming.endMs - segmentTiming.startMs; - const durationText = formatDurationWords(durationMs); - headerVerb = "Worked"; - if (durationText) headerSuffix = `for ${durationText}`; - } else { - headerVerb = "Worked"; - } - - const toolSummary = toolCountSummary(toolParts); - const hasContent = allReasoningText.trim().length > 0 || toolParts.length > 0; - - return ( -
- - {expanded && hasContent ? ( -
- {isActive ? ( - <> - {allReasoningText ? : null} - {toolParts.length > 0 ? : null} - - ) : ( - <> - {allReasoningText ? : null} - {toolParts.map((tool) => ( - - ))} - - )} -
- ) : null} -
- ); -} - -function IssueChatReasoningPart({ text }: { text: string }) { - const lines = text.split("\n").filter((l) => l.trim()); - const lastLine = lines[lines.length - 1] ?? text.slice(-200); - const prevRef = useRef(lastLine); - const [ticker, setTicker] = useState<{ - key: number; - current: string; - exiting: string | null; - }>({ key: 0, current: lastLine, exiting: null }); - - useEffect(() => { - if (lastLine !== prevRef.current) { - const prev = prevRef.current; - prevRef.current = lastLine; - setTicker((t) => ({ key: t.key + 1, current: lastLine, exiting: prev })); - } - }, [lastLine]); - - return ( -
-
- -
-
- {ticker.exiting !== null && ( - setTicker((t) => ({ ...t, exiting: null }))} - > - {ticker.exiting} - - )} - 0 && "cot-line-enter", - )} - > - {ticker.current} - -
-
- ); -} - -function IssueChatRollingToolPart({ toolParts }: { toolParts: ToolCallMessagePart[] }) { - const latest = toolParts[toolParts.length - 1]; - if (!latest) return null; - - const fullText = cleanToolDisplayText(latest); - - const prevRef = useRef(fullText); - const [ticker, setTicker] = useState<{ - key: number; - current: string; - exiting: string | null; - }>({ key: 0, current: fullText, exiting: null }); - - useEffect(() => { - if (fullText !== prevRef.current) { - const prev = prevRef.current; - prevRef.current = fullText; - setTicker((t) => ({ key: t.key + 1, current: fullText, exiting: prev })); - } - }, [fullText]); - - const ToolIcon = getToolIcon(latest.toolName); - const isRunning = latest.result === undefined; - - return ( -
-
- {isRunning ? ( - - ) : ( - - )} -
-
- {ticker.exiting !== null && ( - setTicker((t) => ({ ...t, exiting: null }))} - > - {ticker.exiting} - - )} - 0 && "cot-line-enter", - )} - > - {ticker.current} - -
-
- ); -} - -function CopyablePreBlock({ children, className }: { children: string; className?: string }) { - const [copied, setCopied] = useState(false); - const toastActions = useOptionalToastActions(); - return ( -
-
{children}
- -
- ); -} - -const TOOL_ICON_MAP: Record> = { - // Extend with specific tool icons as they become known -}; - -function getToolIcon(toolName: string): React.ComponentType<{ className?: string }> { - return TOOL_ICON_MAP[toolName] ?? Hammer; -} - -function IssueChatToolPart({ - toolName, - args, - argsText, - result, - isError, -}: { - toolName: string; - args?: unknown; - argsText?: string; - result?: unknown; - isError?: boolean; -}) { - const [open, setOpen] = useState(false); - const rawArgsText = argsText ?? ""; - const parsedArgs = args ?? parseToolPayload(rawArgsText); - const resultText = - typeof result === "string" - ? result - : result === undefined - ? "" - : formatToolPayload(result); - const inputDetails = describeToolInput(toolName, parsedArgs); - const displayName = displayToolName(toolName, parsedArgs); - const isCommand = isCommandTool(toolName, parsedArgs); - const summary = isCommand - ? null - : result === undefined - ? summarizeToolInput(toolName, parsedArgs) - : summarizeToolResult(resultText, false); - const ToolIcon = getToolIcon(toolName); - - const intentDetail = inputDetails.find((d) => d.label === "Intent"); - const title = intentDetail?.value ?? displayName; - const nonIntentDetails = inputDetails.filter((d) => d.label !== "Intent"); - - return ( -
-
- - {open ?
: null} -
- -
- - - {open ? ( -
- {nonIntentDetails.length > 0 ? ( -
-
- Input -
-
- {nonIntentDetails.map((detail) => ( -
-
- {detail.label} -
-
- {detail.value} -
-
- ))} -
-
- ) : rawArgsText ? ( -
-
- Input -
- {rawArgsText} -
- ) : null} - {result !== undefined ? ( -
-
- Result -
- {resultText} -
- ) : null} -
- ) : null} -
-
- ); -} - -function getThreadMessageCopyText(message: ThreadMessage) { - return message.content - .filter((part): part is TextMessagePart => part.type === "text") - .map((part) => part.text) - .join("\n\n"); -} - -const IssueChatTextParts = memo(function IssueChatTextParts({ - message, - recessed = false, -}: { - message: ThreadMessage; - recessed?: boolean; -}) { - return ( - <> - {message.content - .filter((part): part is TextMessagePart => part.type === "text") - .map((part, index) => ( - - ))} - - ); -}); - -function groupAssistantParts( - content: readonly ThreadMessage["content"][number][], -): Array< - | { type: "text"; part: TextMessagePart; index: number } - | { type: "cot"; parts: IssueChatCoTPart[]; startIndex: number } -> { - const groups: Array< - | { type: "text"; part: TextMessagePart; index: number } - | { type: "cot"; parts: IssueChatCoTPart[]; startIndex: number } - > = []; - let pendingCoT: IssueChatCoTPart[] = []; - let pendingStartIndex = -1; - - const flushCoT = () => { - if (pendingCoT.length === 0) return; - groups.push({ type: "cot", parts: pendingCoT, startIndex: pendingStartIndex }); - pendingCoT = []; - pendingStartIndex = -1; - }; - - content.forEach((part, index) => { - if (part.type === "reasoning" || part.type === "tool-call") { - if (pendingCoT.length === 0) pendingStartIndex = index; - pendingCoT.push(part); - return; - } - flushCoT(); - if (part.type === "text") { - groups.push({ type: "text", part, index }); - } - }); - flushCoT(); - - return groups; -} - -const IssueChatAssistantParts = memo(function IssueChatAssistantParts({ - message, - hasCoT, -}: { - message: ThreadMessage; - hasCoT: boolean; -}) { - const groupedParts = useMemo(() => groupAssistantParts(message.content), [message.content]); - return ( - <> - {groupedParts.map((group) => { - if (group.type === "text") { - return ( - - ); - } - return ( - - ); - })} - - ); -}); - -function IssueChatUserMessage({ - message, - isInterruptingQueuedRun, -}: { - message: ThreadMessage; - isInterruptingQueuedRun: boolean; -}) { - const { - onInterruptQueued, - onCancelQueued, - onDeleteComment, - currentUserId, - userProfileMap, - } = useContext(IssueChatCtx); - const custom = message.metadata.custom as Record; - const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; - const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id; - const authorName = typeof custom.authorName === "string" ? custom.authorName : null; - const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null; - const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; - const sourceTrust = isSourceTrustMetadata(custom.sourceTrust) ? custom.sourceTrust : null; - const followUpRequested = custom.followUpRequested === true; - const queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null; - const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued"; - const pending = custom.clientStatus === "pending"; - const deleted = Boolean(custom.deletedAt); - const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; - const [copied, setCopied] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const toastActions = useOptionalToastActions(); - const { - isCurrentUser, - authorName: resolvedAuthorName, - avatarUrl, - } = resolveIssueChatHumanAuthor({ - authorName, - authorUserId, - currentUserId, - userProfileMap, - }); - const authorAvatar = ( - - {avatarUrl ? : null} - {initialsForName(resolvedAuthorName)} - - ); - const canDeleteComment = Boolean(onDeleteComment && isCurrentUser && !queued && !pending && !deleted); - const handleDeleteComment = () => { - if (!canDeleteComment) return; - setDeleteDialogOpen(true); - }; - const confirmDeleteComment = () => { - if (!canDeleteComment) return; - setDeleteDialogOpen(false); - void onDeleteComment?.(commentId); - }; - const messageBody = ( -
-
- {resolvedAuthorName} - - {followUpRequested ? ( - - Follow-up - - ) : null} -
-
- {queued ? ( -
- - {queueBadgeLabel} - - {queueTargetRunId && onInterruptQueued ? ( - - ) : null} - {onCancelQueued ? ( - - ) : null} -
- ) : null} - {deleted ? ( -
Comment deleted
- ) : ( -
- -
- )} -
- - {pending ? ( -
- Sending... -
- ) : ( -
- - - - {message.createdAt ? commentDateLabel(message.createdAt) : ""} - - - - {message.createdAt ? formatDateTime(message.createdAt) : ""} - - - {!deleted ? ( - - ) : null} - {canDeleteComment ? ( - - ) : null} -
- )} -
- ); - - return ( - <> -
-
- {isCurrentUser ? ( - <> - {messageBody} - {authorAvatar} - - ) : ( - <> - {authorAvatar} - {messageBody} - - )} -
-
- - - - Delete comment? - - This will replace the comment with a deleted-comment marker. - - - - - - - - - - ); -} - -function IssueChatAssistantMessage({ - message, - activeVote, - isRunActive, - isStoppingRun, -}: { - message: ThreadMessage; - activeVote: FeedbackVoteValue | null; - isRunActive: boolean; - isStoppingRun: boolean; -}) { - const { - feedbackDataSharingPreference, - feedbackTermsUrl, - onVote, - agentMap, - onStopRun, - stopRunLabel = "Stop run", - stoppingRunLabel = "Stopping...", - stopRunVariant = "stop", - runFinalizationActions = [], - } = useContext(IssueChatCtx); - const custom = message.metadata.custom as Record; - const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; - const authorName = typeof custom.authorName === "string" - ? custom.authorName - : typeof custom.runAgentName === "string" - ? custom.runAgentName - : "Agent"; - const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null; - const runId = typeof custom.runId === "string" ? custom.runId : null; - const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; - const runStatus = typeof custom.runStatus === "string" ? custom.runStatus : null; - const agentId = authorAgentId ?? runAgentId; - const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined; - const commentId = typeof custom.commentId === "string" ? custom.commentId : null; - const sourceTrust = isSourceTrustMetadata(custom.sourceTrust) ? custom.sourceTrust : null; - const notices = Array.isArray(custom.notices) - ? custom.notices.filter((notice): notice is string => typeof notice === "string" && notice.length > 0) - : []; - const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : ""; - const isRunning = message.role === "assistant" && message.status?.type === "running"; - const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null; - const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running"); - const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null; - const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call"); - const deleted = Boolean(custom.deletedAt); - const isFoldable = !isRunning && !!chainOfThoughtLabel; - const [folded, setFolded] = useState(isFoldable); - const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable }); - const [copied, setCopied] = useState(false); - const toastActions = useOptionalToastActions(); - const copyText = deleted ? "" : getThreadMessageCopyText(message); - - // Derive fold state synchronously during render (not in useEffect) so the - // browser never paints the un-folded intermediate state — prevents the - // visible "jump" when loading a page with already-folded work sections. - if (message.id !== prevFoldKey.messageId || isFoldable !== prevFoldKey.isFoldable) { - const nextFolded = resolveAssistantMessageFoldedState({ - messageId: message.id, - currentFolded: folded, - isFoldable, - previousMessageId: prevFoldKey.messageId, - previousIsFoldable: prevFoldKey.isFoldable, - }); - setPrevFoldKey({ messageId: message.id, isFoldable }); - if (nextFolded !== folded) { - setFolded(nextFolded); - } - } - - const handleVote = async ( - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => { - if (!commentId || !onVote) return; - await onVote(commentId, vote, options); - }; - - const followUpRequested = custom.followUpRequested === true; - - return ( -
-
- - {agentIcon ? ( - - ) : ( - {initialsForName(authorName)} - )} - - -
- {isFoldable ? ( - - ) : ( -
- {authorName} - - {followUpRequested ? ( - - Follow-up - - ) : null} - {isRunning ? ( - - - Running - - ) : null} -
- )} - - {deleted ? ( -
- Comment deleted -
- ) : !folded ? ( - <> -
- - {message.content.length === 0 && waitingText ? ( -
- - {agentIcon ? ( - - ) : ( - - )} - {waitingText} - -
- ) : null} - {notices.length > 0 ? ( -
- {notices.map((notice, index) => ( -
- {notice} -
- ))} -
- ) : null} -
- -
- - {commentId && onVote ? ( - - ) : null} - - - - {message.createdAt ? commentDateLabel(message.createdAt) : ""} - - - - {message.createdAt ? formatDateTime(message.createdAt) : ""} - - - - - - - - { - void copyTextToClipboard(copyText).catch((error) => { - toastActions?.pushToast({ - title: "Copy failed", - body: error instanceof Error ? error.message : "Unable to copy message", - tone: "error", - }); - }); - }} - > - - Copy message - - {canStopRun && onStopRun && runId ? ( - { - void onStopRun(runId); - }} - > - {stopRunVariant === "pause" ? ( - - ) : ( - - )} - {isStoppingRun ? stoppingRunLabel : stopRunLabel} - - ) : null} - {canStopRun && runId - ? runFinalizationActions.map((action) => ( - { - void action.onSelect(runId); - }} - > - {action.id === "cancel" ? ( - - ) : ( - - )} - {action.isPending ? action.pendingLabel : action.label} - - )) - : null} - {runHref ? ( - - - - View run - - - ) : null} - - -
- - ) : null} -
-
-
- ); -} - -function IssueChatFeedbackButtons({ - activeVote, - sharingPreference = "prompt", - termsUrl, - onVote, -}: { - activeVote: FeedbackVoteValue | null; - sharingPreference: FeedbackDataSharingPreference; - termsUrl: string | null; - onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise; -}) { - const [isSaving, setIsSaving] = useState(false); - const [optimisticVote, setOptimisticVote] = useState(null); - const [reasonOpen, setReasonOpen] = useState(false); - const [downvoteReason, setDownvoteReason] = useState(""); - const [pendingSharingDialog, setPendingSharingDialog] = useState<{ - vote: FeedbackVoteValue; - reason?: string; - } | null>(null); - const visibleVote = optimisticVote ?? activeVote ?? null; - - useEffect(() => { - if (optimisticVote && activeVote === optimisticVote) setOptimisticVote(null); - }, [activeVote, optimisticVote]); - - async function doVote( - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) { - setIsSaving(true); - try { - await onVote(vote, options); - } catch { - setOptimisticVote(null); - } finally { - setIsSaving(false); - } - } - - function handleVote(vote: FeedbackVoteValue, reason?: string) { - setOptimisticVote(vote); - if (sharingPreference === "prompt") { - setPendingSharingDialog({ vote, ...(reason ? { reason } : {}) }); - return; - } - const allowSharing = sharingPreference === "allowed"; - void doVote(vote, { - ...(allowSharing ? { allowSharing: true } : {}), - ...(reason ? { reason } : {}), - }); - } - - function handleThumbsUp() { - handleVote("up"); - } - - function handleThumbsDown() { - setOptimisticVote("down"); - setReasonOpen(true); - // Submit the initial down vote right away - handleVote("down"); - } - - function handleSubmitReason() { - if (!downvoteReason.trim()) return; - // Re-submit with reason attached - if (sharingPreference === "prompt") { - setPendingSharingDialog({ vote: "down", reason: downvoteReason }); - } else { - const allowSharing = sharingPreference === "allowed"; - void doVote("down", { - ...(allowSharing ? { allowSharing: true } : {}), - reason: downvoteReason, - }); - } - setReasonOpen(false); - setDownvoteReason(""); - } - - return ( - <> - - - - - - -
What could have been better?
-