diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 9e7d8a64..b8d90b05 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { + EventId, type GitBranch, ORCHESTRATION_WS_METHODS, type MessageId, @@ -40,6 +41,12 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; const ONBOARDING_STORAGE_KEY = "okcode:onboarding-completed:v1"; +const PLAN_FOLLOW_UP_TURN_ID = "turn-plan-follow-up" as TurnId; +const PLAN_IMPLEMENTATION_TURN_ID = "turn-plan-implementation" as TurnId; +const PLAN_FOLLOW_UP_ID = "plan-follow-up-browser"; +const PLAN_STEP_INSPECT_FILES = "Inspect files"; +const PLAN_STEP_APPLY_PATCH = "Apply patch"; +const PLAN_STEP_VERIFY_SIDEBAR = "Verify sidebar stability"; interface WsRequestEnvelope { id: string; @@ -462,6 +469,147 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function replaceThreadInSnapshot( + snapshot: OrchestrationReadModel, + updater: ( + thread: OrchestrationReadModel["threads"][number], + ) => OrchestrationReadModel["threads"][number], +): OrchestrationReadModel { + const nextThreadIndex = snapshot.threads.findIndex((thread) => thread.id === THREAD_ID); + if (nextThreadIndex < 0) { + return snapshot; + } + const nextThreads = [...snapshot.threads]; + nextThreads[nextThreadIndex] = updater(nextThreads[nextThreadIndex]!); + return { + ...snapshot, + threads: nextThreads, + }; +} + +function createPlanFollowUpSnapshot(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-plan-follow-up-target" as MessageId, + targetText: "plan follow-up thread", + }); + const planMarkdown = [ + "# Stabilize plan implementation flow", + "", + `- ${PLAN_STEP_INSPECT_FILES}`, + `- ${PLAN_STEP_APPLY_PATCH}`, + `- ${PLAN_STEP_VERIFY_SIDEBAR}`, + ].join("\n"); + + return replaceThreadInSnapshot(snapshot, (thread) => { + if (!thread.session) { + return thread; + } + return { + ...thread, + interactionMode: "plan", + latestTurn: { + turnId: PLAN_FOLLOW_UP_TURN_ID, + state: "completed", + requestedAt: isoAt(1_100), + startedAt: isoAt(1_101), + completedAt: isoAt(1_102), + assistantMessageId: null, + }, + proposedPlans: [ + { + id: PLAN_FOLLOW_UP_ID, + turnId: PLAN_FOLLOW_UP_TURN_ID, + planMarkdown, + implementedAt: null, + implementationThreadId: null, + createdAt: isoAt(1_100), + updatedAt: isoAt(1_101), + }, + ], + session: { + ...thread.session, + status: "ready", + activeTurnId: null, + updatedAt: isoAt(1_102), + }, + updatedAt: isoAt(1_102), + }; + }); +} + +function createRunningPlanImplementationSnapshot(snapshotSequence: number): OrchestrationReadModel { + const snapshot = createPlanFollowUpSnapshot(); + const snapshotWithRunningThread = replaceThreadInSnapshot(snapshot, (thread) => { + if (!thread.session) { + return thread; + } + const activities: OrchestrationReadModel["threads"][number]["activities"] = [ + { + id: EventId.makeUnsafe("evt-plan-implementation-1"), + tone: "info", + kind: "turn.plan.updated", + summary: "Plan updated", + payload: { + explanation: "Executing the queued plan steps.", + plan: [ + { step: PLAN_STEP_INSPECT_FILES, status: "completed" }, + { step: PLAN_STEP_APPLY_PATCH, status: "in_progress" }, + { step: PLAN_STEP_VERIFY_SIDEBAR, status: "pending" }, + ], + }, + turnId: PLAN_IMPLEMENTATION_TURN_ID, + sequence: 40, + createdAt: isoAt(1_202), + }, + { + id: EventId.makeUnsafe("evt-plan-implementation-2"), + tone: "tool", + kind: "tool.updated", + summary: "Editing files", + payload: { + itemType: "command_execution", + status: "in_progress", + title: "Apply patch", + detail: "Editing files", + }, + turnId: PLAN_IMPLEMENTATION_TURN_ID, + sequence: 41, + createdAt: isoAt(1_203), + }, + ]; + + return { + ...thread, + interactionMode: "code", + latestTurn: { + turnId: PLAN_IMPLEMENTATION_TURN_ID, + state: "running", + requestedAt: isoAt(1_200), + startedAt: isoAt(1_201), + completedAt: null, + assistantMessageId: null, + sourceProposedPlan: { + threadId: THREAD_ID, + planId: PLAN_FOLLOW_UP_ID, + }, + }, + activities, + session: { + ...thread.session, + status: "running", + activeTurnId: PLAN_IMPLEMENTATION_TURN_ID, + updatedAt: isoAt(1_204), + }, + updatedAt: isoAt(1_204), + }; + }); + + return { + ...snapshotWithRunningThread, + snapshotSequence, + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; const fixtureThread = fixture.snapshot.threads.find((thread) => thread.id === THREAD_ID) ?? null; @@ -679,6 +827,24 @@ async function waitForSendButton(): Promise { ); } +async function waitForPlanFollowUpImplementButton(): Promise { + return waitForElement( + () => + [...document.querySelectorAll('button[type="submit"]')].find( + (button) => button.textContent?.trim() === "Implement", + ) ?? null, + "Unable to find plan implement button.", + ); +} + +function submitWithButton(button: HTMLButtonElement): void { + const form = button.closest("form"); + if (!(form instanceof HTMLFormElement)) { + throw new Error("Unable to locate composer form for submit button."); + } + form.requestSubmit(button); +} + function isVisibleElement(element: Element | null): element is HTMLElement { return ( element instanceof HTMLElement && @@ -910,6 +1076,15 @@ async function mountChatView(options: { }; } +async function syncFixtureSnapshot(snapshot: OrchestrationReadModel): Promise { + fixture = { + ...fixture, + snapshot, + }; + useStore.getState().syncServerReadModel(snapshot); + await waitForLayout(); +} + async function measureUserRowAtViewport(options: { snapshot: OrchestrationReadModel; targetMessageId: MessageId; @@ -1006,6 +1181,141 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("implements a proposed plan on the same thread without tripping a render loop", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createPlanFollowUpSnapshot(), + }); + + try { + await waitForServerConfigToApply(); + await vi.waitFor(async () => { + expect(await readCurrentInteractionModeLabel()).toBe("Plan"); + }); + + const sendButton = await waitForPlanFollowUpImplementButton(); + await vi.waitFor(() => { + expect(sendButton.disabled).toBe(false); + }); + submitWithButton(sendButton); + + await vi.waitFor(() => { + const request = wsRequests.find( + (entry) => + entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + typeof entry.command === "object" && + entry.command !== null && + "type" in entry.command && + entry.command.type === "thread.turn.start" && + "threadId" in entry.command && + entry.command.threadId === THREAD_ID, + ); + expect(request).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + command: { + type: "thread.turn.start", + threadId: THREAD_ID, + interactionMode: "code", + sourceProposedPlan: { + threadId: THREAD_ID, + planId: PLAN_FOLLOW_UP_ID, + }, + }, + }); + }); + + await vi.waitFor(async () => { + expect(await readCurrentInteractionModeLabel()).toBe("Code"); + }); + + await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(2)); + + await vi.waitFor( + () => { + expect( + document.querySelector('button[aria-label="Close plan sidebar"]'), + ).toBeTruthy(); + expect(document.body.textContent).toContain(PLAN_STEP_INSPECT_FILES); + expect(document.body.textContent).toContain(PLAN_STEP_APPLY_PATCH); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect( + consoleErrorSpy.mock.calls.some((call) => + call.some( + (value) => + typeof value === "string" && + (value.includes("Too many re-renders") || + value.includes("Minified React error #301")), + ), + ), + ).toBe(false); + } finally { + consoleErrorSpy.mockRestore(); + await mounted.cleanup(); + } + }); + + it("keeps the same-thread implementation surface stable across repeated identical snapshots", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createPlanFollowUpSnapshot(), + }); + + try { + await waitForServerConfigToApply(); + const sendButton = await waitForPlanFollowUpImplementButton(); + await vi.waitFor(() => { + expect(sendButton.disabled).toBe(false); + }); + submitWithButton(sendButton); + + await vi.waitFor(() => { + const request = wsRequests.find( + (entry) => + entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + typeof entry.command === "object" && + entry.command !== null && + "type" in entry.command && + entry.command.type === "thread.turn.start" && + "threadId" in entry.command && + entry.command.threadId === THREAD_ID, + ); + expect(request).toBeTruthy(); + }); + + await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(2)); + await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(3)); + await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(4)); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain(PLAN_STEP_VERIFY_SIDEBAR); + expect(document.body.textContent).not.toContain("Plan ready"); + expect(document.querySelectorAll('[aria-label="Close plan sidebar"]')).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect( + consoleErrorSpy.mock.calls.some((call) => + call.some( + (value) => + typeof value === "string" && + (value.includes("Too many re-renders") || + value.includes("Minified React error #301")), + ), + ), + ).toBe(false); + } finally { + consoleErrorSpy.mockRestore(); + await mounted.cleanup(); + } + }); + it.each(TEXT_VIEWPORT_MATRIX)( "keeps long user message estimate close at the $name viewport", async (viewport) => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d85d0713..83ada820 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -69,19 +69,20 @@ import { replaceTextRange, } from "../composer-logic"; import { - derivePendingApprovals, - derivePendingUserInputs, + derivePendingApprovalsFromOrderedActivities, + derivePendingUserInputsFromOrderedActivities, derivePhase, deriveTimelineEntries, deriveActiveWorkStartedAt, - deriveActivePlanState, + deriveActivePlanStateFromOrderedActivities, findSidebarProposedPlan, findLatestProposedPlan, - deriveWorkLogEntries, + deriveWorkLogEntriesFromOrderedActivities, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, + orderThreadActivities, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { @@ -1001,21 +1002,29 @@ export default function ChatView({ sendStartedAt, ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const orderedThreadActivities = useMemo( + () => orderThreadActivities(threadActivities), + [threadActivities], + ); const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], + () => + deriveWorkLogEntriesFromOrderedActivities( + orderedThreadActivities, + activeLatestTurn?.turnId ?? undefined, + ), + [activeLatestTurn?.turnId, orderedThreadActivities], ); const latestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), [activeLatestTurn?.turnId, threadActivities], ); const pendingApprovals = useMemo( - () => derivePendingApprovals(threadActivities), - [threadActivities], + () => derivePendingApprovalsFromOrderedActivities(orderedThreadActivities), + [orderedThreadActivities], ); const pendingUserInputs = useMemo( - () => derivePendingUserInputs(threadActivities), - [threadActivities], + () => derivePendingUserInputsFromOrderedActivities(orderedThreadActivities), + [orderedThreadActivities], ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( @@ -1070,14 +1079,20 @@ export default function ChatView({ [activeLatestTurn, activeThread?.id, latestTurnSettled, threads], ); const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], + () => + deriveActivePlanStateFromOrderedActivities( + orderedThreadActivities, + activeLatestTurn?.turnId ?? undefined, + ), + [activeLatestTurn?.turnId, orderedThreadActivities], ); const activePlanTurnId = activePlan?.turnId ?? null; const activePendingUserInputRequestId = activePendingUserInput?.requestId ?? null; const hasPendingPlanFeedback = activePendingUserInputRequestId !== null && (activePlanTurnId !== null || interactionMode === "plan"); + const planSidebarAutoOpenTurnKey = + activePlanTurnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -1133,21 +1148,17 @@ export default function ChatView({ activePendingProgress?.activeQuestion?.id, ]); useEffect(() => { - if (!hasPendingPlanFeedback) { + if (!hasPendingPlanFeedback || planSidebarOpen) { return; } - const turnKey = - activePlanTurnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null; - if (!turnKey || planSidebarDismissedForTurnRef.current === turnKey) { + if ( + !planSidebarAutoOpenTurnKey || + planSidebarDismissedForTurnRef.current === planSidebarAutoOpenTurnKey + ) { return; } setPlanSidebarOpen(true); - }, [ - activeLatestTurn?.turnId, - activePlanTurnId, - hasPendingPlanFeedback, - sidebarProposedPlan?.turnId, - ]); + }, [hasPendingPlanFeedback, planSidebarAutoOpenTurnKey, planSidebarOpen]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; diff --git a/apps/web/src/hooks/useFileViewNavigation.test.tsx b/apps/web/src/hooks/useFileViewNavigation.test.tsx new file mode 100644 index 00000000..84949cd9 --- /dev/null +++ b/apps/web/src/hooks/useFileViewNavigation.test.tsx @@ -0,0 +1,182 @@ +import { ProjectId, ThreadId } from "@okcode/contracts"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useCodeViewerStore } from "~/codeViewerStore"; +import { useStore } from "~/store"; +import { useFileViewNavigation } from "./useFileViewNavigation"; + +const navigateMock = vi.fn(); + +let currentThreadId: string | null = null; + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, + useParams: ({ + select, + }: { + select?: (params: Record) => unknown; + } = {}) => { + const params = currentThreadId ? { threadId: currentThreadId } : {}; + return typeof select === "function" ? select(params) : params; + }, +})); + +const baseStoreState = useStore.getState(); + +const projectId = ProjectId.makeUnsafe("project-1"); +const firstThreadId = ThreadId.makeUnsafe("thread-1"); +const secondThreadId = ThreadId.makeUnsafe("thread-2"); + +let renderer: ReactTestRenderer | null = null; +let latestOpenInViewer: ((cwd: string, relativePath: string) => void) | null = null; + +function HookHarness() { + latestOpenInViewer = useFileViewNavigation(); + return null; +} + +function seedThreads() { + useStore.setState({ + projects: [ + { + id: projectId, + name: "OK Code", + cwd: "/repo/project", + model: "gpt-5.4", + expanded: true, + scripts: [], + }, + ], + threads: [ + { + id: firstThreadId, + codexThreadId: null, + kind: "thread", + projectId, + title: "First", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: "chat", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-05-04T10:00:00.000Z", + updatedAt: "2026-05-04T10:00:00.000Z", + latestTurn: null, + branch: "main", + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + { + id: secondThreadId, + codexThreadId: null, + kind: "thread", + projectId, + title: "Second", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: "chat", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-05-04T10:00:01.000Z", + updatedAt: "2026-05-04T10:00:01.000Z", + latestTurn: null, + branch: "main", + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + ], + threadsHydrated: true, + }); +} + +async function mountHarness() { + await act(async () => { + renderer = create(); + }); +} + +async function unmountHarness() { + if (!renderer) { + return; + } + await act(async () => { + renderer?.unmount(); + }); + renderer = null; +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + currentThreadId = null; + navigateMock.mockReset(); + latestOpenInViewer = null; + seedThreads(); + useCodeViewerStore.setState({ + isOpen: false, + tabs: [], + activeTabId: null, + pendingContext: null, + }); +}); + +afterEach(async () => { + await unmountHarness(); + useStore.setState(baseStoreState); + currentThreadId = null; +}); + +describe("useFileViewNavigation", () => { + it("keeps the callback stable across thread snapshot churn", async () => { + await mountHarness(); + + const firstCallback = latestOpenInViewer; + expect(firstCallback).not.toBeNull(); + + await act(async () => { + useStore.setState((state) => ({ + threads: state.threads.map((thread) => + thread.id === secondThreadId + ? { ...thread, updatedAt: "2026-05-04T10:05:00.000Z" } + : { ...thread }, + ), + })); + }); + + expect(latestOpenInViewer).toBe(firstCallback); + }); + + it("reads the latest thread list lazily when navigating from outside a thread page", async () => { + await mountHarness(); + + const initialCallback = latestOpenInViewer; + expect(initialCallback).not.toBeNull(); + + await act(async () => { + useStore.setState((state) => ({ + threads: state.threads.map((thread) => + thread.id === firstThreadId + ? { ...thread, updatedAt: "2026-05-04T09:59:00.000Z" } + : thread.id === secondThreadId + ? { ...thread, updatedAt: "2026-05-04T10:06:00.000Z" } + : { ...thread }, + ), + })); + }); + + await act(async () => { + initialCallback?.("/repo/project", "README.md"); + }); + + expect(navigateMock).toHaveBeenCalledWith({ + to: "/$threadId", + params: { threadId: secondThreadId }, + }); + }); +}); diff --git a/apps/web/src/hooks/useFileViewNavigation.ts b/apps/web/src/hooks/useFileViewNavigation.ts index 45b6a2ca..9524d82a 100644 --- a/apps/web/src/hooks/useFileViewNavigation.ts +++ b/apps/web/src/hooks/useFileViewNavigation.ts @@ -14,7 +14,6 @@ export function useFileViewNavigation() { strict: false, select: (params) => (params as Record).threadId ?? null, }); - const threads = useStore((s) => s.threads); return useCallback( (cwd: string, relativePath: string) => { @@ -22,6 +21,7 @@ export function useFileViewNavigation() { // If not already on a thread page, navigate to the most recent thread // so the workspace inline sidebar is visible. if (!threadId) { + const threads = useStore.getState().threads; const sorted = threads.toSorted((a, b) => (b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt), ); @@ -33,6 +33,6 @@ export function useFileViewNavigation() { } } }, - [navigate, openFile, threadId, threads], + [navigate, openFile, threadId], ); } diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5c4e0e90..26721acc 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -192,11 +192,16 @@ function isStalePendingRequestFailureDetail(detail: string | undefined): boolean export function derivePendingApprovals( activities: ReadonlyArray, +): PendingApproval[] { + return derivePendingApprovalsFromOrderedActivities(orderThreadActivities(activities)); +} + +export function derivePendingApprovalsFromOrderedActivities( + orderedActivities: ReadonlyArray, ): PendingApproval[] { const openByRequestId = new Map(); - const ordered = [...activities].toSorted(compareActivitiesByOrder); - for (const activity of ordered) { + for (const activity of orderedActivities) { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) @@ -297,11 +302,16 @@ function parseUserInputQuestions( export function derivePendingUserInputs( activities: ReadonlyArray, +): PendingUserInput[] { + return derivePendingUserInputsFromOrderedActivities(orderThreadActivities(activities)); +} + +export function derivePendingUserInputsFromOrderedActivities( + orderedActivities: ReadonlyArray, ): PendingUserInput[] { const openByRequestId = new Map(); - const ordered = [...activities].toSorted(compareActivitiesByOrder); - for (const activity of ordered) { + for (const activity of orderedActivities) { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) @@ -348,8 +358,17 @@ export function deriveActivePlanState( activities: ReadonlyArray, latestTurnId: TurnId | undefined, ): ActivePlanState | null { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const candidates = ordered.filter((activity) => { + return deriveActivePlanStateFromOrderedActivities( + orderThreadActivities(activities), + latestTurnId, + ); +} + +export function deriveActivePlanStateFromOrderedActivities( + orderedActivities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): ActivePlanState | null { + const candidates = orderedActivities.filter((activity) => { if (activity.kind !== "turn.plan.updated") { return false; } @@ -477,8 +496,14 @@ export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, ): WorkLogEntry[] { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const entries = ordered + return deriveWorkLogEntriesFromOrderedActivities(orderThreadActivities(activities), latestTurnId); +} + +export function deriveWorkLogEntriesFromOrderedActivities( + orderedActivities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): WorkLogEntry[] { + const entries = orderedActivities .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) .filter((activity) => activity.kind !== "tool.started") .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") @@ -857,6 +882,12 @@ function extractChangedFiles(payload: Record | null): string[] return changedFiles; } +export function orderThreadActivities( + activities: ReadonlyArray, +): OrchestrationThreadActivity[] { + return [...activities].toSorted(compareActivitiesByOrder); +} + function compareActivitiesByOrder( left: OrchestrationThreadActivity, right: OrchestrationThreadActivity,