Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ThreadTimelineSurfaceProps } from "./ThreadTimelineSurface.js";
import { ThreadTimelinePanelContent } from "./ThreadTimelinePanelContent.js";
import type { UseThreadTimelineControllerResult } from "./useThreadTimelineController.js";

const mocks = vi.hoisted(() => ({
displayStatus: "idle",
surfaceProps: [] as ThreadTimelineSurfaceProps[],
threadStatus: "idle",
timeline: undefined as unknown as UseThreadTimelineControllerResult,
}));

vi.mock("@/hooks/queries/thread-queries", () => ({
useThread: () => ({
data: {
runtime: { displayStatus: mocks.displayStatus },
status: mocks.threadStatus,
},
error: null,
}),
}));

vi.mock("./useThreadTimelineController.js", () => ({
useThreadTimelineController: () => mocks.timeline,
}));

vi.mock("./ThreadTimelineSurface.js", () => ({
ThreadTimelineSurface: (props: ThreadTimelineSurfaceProps) => {
mocks.surfaceProps.push(props);
return (
<div data-testid="timeline-surface">
{props.showOngoingIndicator ? (
<div>{props.ongoingIndicatorLabel ?? "Working"}</div>
) : null}
</div>
);
},
}));

function makeTimeline(
overrides: Partial<UseThreadTimelineControllerResult> = {},
): UseThreadTimelineControllerResult {
return {
activeThinking: null,
activeWorkflow: null,
activeBackgroundCommands: [],
contextWindowUsage: undefined,
goal: null,
hasOlderTimelineRows: false,
isLoadingOlderTimelineRows: false,
loadOlderTimelineRows: vi.fn(),
pendingTodos: null,
timelineError: null,
timelineLoading: false,
timelineRows: [],
...overrides,
} as UseThreadTimelineControllerResult;
}

function lastSurfaceProps(): ThreadTimelineSurfaceProps {
const props = mocks.surfaceProps.at(-1);
if (!props) {
throw new Error("ThreadTimelineSurface did not render");
}
return props;
}

beforeEach(() => {
mocks.displayStatus = "idle";
mocks.surfaceProps = [];
mocks.threadStatus = "idle";
mocks.timeline = makeTimeline();
});

afterEach(() => {
vi.clearAllMocks();
});

describe("ThreadTimelinePanelContent background task indicator", () => {
it("shows a background-only working indicator for an idle thread with an active workflow", () => {
mocks.timeline = makeTimeline({
activeWorkflow: {} as UseThreadTimelineControllerResult["activeWorkflow"],
});

const markup = renderToStaticMarkup(
<ThreadTimelinePanelContent threadId="thr_workflow" />,
);

expect(markup).toContain("Background work running");
expect(lastSurfaceProps()).toMatchObject({
ongoingIndicatorLabel: "Background work running",
showOngoingIndicator: true,
});
});

it("shows a background-only working indicator for an idle thread with active background commands", () => {
mocks.timeline = makeTimeline({
activeBackgroundCommands: [
{},
] as UseThreadTimelineControllerResult["activeBackgroundCommands"],
});

const markup = renderToStaticMarkup(
<ThreadTimelinePanelContent threadId="thr_command" />,
);

expect(markup).toContain("Background work running");
expect(lastSurfaceProps()).toMatchObject({
ongoingIndicatorLabel: "Background work running",
showOngoingIndicator: true,
});
});

it("does not show the background-task indicator for stopping threads", () => {
mocks.threadStatus = "stopping";
mocks.timeline = makeTimeline({
activeWorkflow: {} as UseThreadTimelineControllerResult["activeWorkflow"],
});

const markup = renderToStaticMarkup(
<ThreadTimelinePanelContent threadId="thr_stopping" />,
);

expect(markup).not.toContain("Background work running");
expect(lastSurfaceProps()).toMatchObject({
showOngoingIndicator: false,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,25 @@ export function ThreadTimelinePanelContent({
const displayStatus = threadQuery.data?.runtime.displayStatus ?? "idle";
const isProvisioningDisplayStatus =
displayStatus === "provisioning" || displayStatus === "starting";
const isRuntimeOngoing = isRunningThreadRuntimeDisplayStatus(displayStatus);
const hasActiveBackgroundTask =
resolvedTimeline.activeWorkflow !== null ||
resolvedTimeline.activeBackgroundCommands.length > 0;
const ongoingIndicatorLabel =
displayStatus === "host-reconnecting"
? "Waiting for reconnection"
: isProvisioningDisplayStatus
? provisioningLabel
: hasActiveBackgroundTask && !isRuntimeOngoing
? "Background work running"
: undefined;
const showOngoingIndicator =
threadQuery.data?.status !== "stopping" &&
(isProvisioningDisplayStatus ||
(!resolvedTimeline.timelineLoading &&
(isTurnSubmitting ||
isRunningThreadRuntimeDisplayStatus(displayStatus))));
isRuntimeOngoing ||
hasActiveBackgroundTask)));
const timelineRows = resolvedTimeline.timelineRows;
const isChildThreadMissing =
threadQuery.error instanceof HttpError && threadQuery.error.status === 404;
Expand Down
9 changes: 8 additions & 1 deletion apps/app/src/views/thread-detail/ThreadDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,8 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
} = useThreadTimelineController({
threadId: threadId ?? "",
});
const hasActiveBackgroundTask =
activeWorkflow !== null || activeBackgroundCommands.length > 0;
const sendMessage = useSendThreadMessage();
const requestEnvironmentAction = useRequestEnvironmentAction();
const [pullRequestMergeMethod, setPullRequestMergeMethod] = useAtom(
Expand Down Expand Up @@ -1838,6 +1840,9 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
</PageShell>
);
}
const isRuntimeOngoing = isRunningThreadRuntimeDisplayStatus(
thread.runtime.displayStatus,
);
const hasAssignableParent = parentSelectorOptions.some(
(option) => option.value !== "none",
);
Expand Down Expand Up @@ -2176,11 +2181,13 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
// own inline shimmer row, so the bottom indicator would just
// duplicate it.
!hasPendingInteraction &&
isRunningThreadRuntimeDisplayStatus(thread.runtime.displayStatus) &&
(isRuntimeOngoing || hasActiveBackgroundTask) &&
!isThreadTimelinePending,
ongoingIndicatorLabel:
thread.runtime.displayStatus === "host-reconnecting"
? "Waiting for reconnection"
: hasActiveBackgroundTask && !isRuntimeOngoing
? "Background work running"
: undefined,
timelineRows,
isStopping: thread.status === "stopping",
Expand Down
50 changes: 44 additions & 6 deletions apps/server/src/services/threads/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
listStoredClientTurnRequestIdsInRange,
listStoredEventRowsInRange,
listLatestBackgroundTaskStateRowsByItemIds,
listOpenBackgroundTaskStateRowsForThread,
listStoredTimelineWindowEventRows,
listStoredTurnInputAcceptedRowsByClientRequestIds,
listStoredTurnStartedRowsByTurnIdsUpToSequence,
Expand Down Expand Up @@ -540,6 +541,39 @@ function ensureTimelineWindowBackgroundTaskStateRows(
return mergeStoredEventRowsById([...args.rows, ...stateRows]);
}

function ensureLatestTimelineOpenBackgroundTaskStateRows(
db: DbConnection,
args: TimelineWindowRowsArgs & { page: ThreadTimelinePageRequest },
): StoredEventRow[] {
if (args.page.kind !== "latest") {
return [...args.rows];
}

const openTaskRows = listOpenBackgroundTaskStateRowsForThread(db, {
threadId: args.threadId,
});
if (openTaskRows.length === 0) {
return [...args.rows];
}

const selectedRowIds = new Set(args.rows.map((row) => row.id));
const projectedOpenTaskRows = openTaskRows.map((row) =>
selectedRowIds.has(row.id)
? row
: {
...row,
// Injected open-task rows feed active cards, not exact transcript
// replay. A task whose latest state is only item/started remains
// turn-scoped in storage, so replay it as the thread-scoped progress
// snapshot that background-task projections already accept.
type: "item/backgroundTask/progress" as const,
scopeKind: "thread" as const,
turnId: null,
},
);
return mergeStoredEventRowsById([...projectedOpenTaskRows, ...args.rows]);
}

interface ResolveTimelineSegmentWindowArgs {
page: ThreadTimelinePageRequest;
threadId: string;
Expand Down Expand Up @@ -636,15 +670,19 @@ function selectStandardTimelineEventRows(
const beforeSequence = window.beforeSequence;
const sequenceStart = window.sequenceStart;

const selectedRows = ensureTimelineWindowBackgroundTaskStateRows(db, {
const selectedRows = ensureLatestTimelineOpenBackgroundTaskStateRows(db, {
page,
threadId: thread.id,
rows: ensureTimelineWindowTurnStartedRows(db, {
rows: ensureTimelineWindowBackgroundTaskStateRows(db, {
threadId: thread.id,
rows: listStoredTimelineWindowEventRows(db, {
beforeSequence,
excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES,
sequenceStart,
rows: ensureTimelineWindowTurnStartedRows(db, {
threadId: thread.id,
rows: listStoredTimelineWindowEventRows(db, {
beforeSequence,
excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES,
sequenceStart,
threadId: thread.id,
}),
}),
}),
});
Expand Down
Loading
Loading