Skip to content
Closed
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
27 changes: 27 additions & 0 deletions static/app/components/core/chat/thinkingBlock.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ export function CompletedDemo() {
</ThinkingBlock>
```

## Without the icon

Pass `showIcon={false}` when the block sits beside a spinning Seer of its own,
so the same loading state isn't shown twice. The disclosure's chevron leads the
title instead.

export function WithoutIconDemo() {
const [start] = useState(() => new Date());
return (
<ThinkingBlock title="Querying spans..." startTime={start} showIcon={false}>
<Text size="sm" monospace variant="muted">
Searching project data.
</Text>
</ThinkingBlock>
);
}

<Storybook.Demo>
<WithoutIconDemo />
</Storybook.Demo>

```jsx
<ThinkingBlock title="Querying spans..." startTime={new Date()} showIcon={false}>
…
</ThinkingBlock>
```

## Duration formats

Under a minute the timer shows tenths of a second ("5.3s"). From a minute on it
Expand Down
20 changes: 18 additions & 2 deletions static/app/components/core/chat/thinkingBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,21 @@ interface ThinkingBlockProps {
title: string;
children?: React.ReactNode;
endTime?: Date;
/**
* Whether to lead the title with the Seer icon. Turn it off when the block
* sits beside a spinning Seer of its own, so one loading state isn't shown
* twice; the disclosure's chevron leads instead.
*/
showIcon?: boolean;
}

export function ThinkingBlock({title, startTime, endTime, children}: ThinkingBlockProps) {
export function ThinkingBlock({
title,
startTime,
endTime,
children,
showIcon = true,
}: ThinkingBlockProps) {
const {t} = useTranslation();
const elapsed = useElapsedTime(startTime, endTime);
const isActive = !endTime;
Expand Down Expand Up @@ -103,7 +115,11 @@ export function ThinkingBlock({title, startTime, endTime, children}: ThinkingBlo
>
<Global styles={streamingAnimationStyles} />
<Disclosure.Title
leadingItems={<IconSeer size="xs" animation={isActive ? 'loading' : undefined} />}
leadingItems={
showIcon ? (
<IconSeer size="xs" animation={isActive ? 'loading' : undefined} />
) : undefined
}
trailingItems={
<Text variant="secondary" size="sm" align="right" monospace>
{formatElapsedDuration(elapsed)}
Expand Down
4 changes: 4 additions & 0 deletions static/app/views/investigations/detail/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization';
import {makeTestQueryClient} from 'sentry-test/queryClient';
import {
act,
cleanup,
fireEvent,
render,
renderGlobalModal,
Expand Down Expand Up @@ -382,6 +383,9 @@ describe('Investigation detail', () => {

afterEach(async () => {
try {
// Unmount first: a running run's status block keeps a live clock, and
// flushing the query cache's timers with it mounted ticks it for minutes.
cleanup();
await act(() => jest.runOnlyPendingTimersAsync());
} finally {
jest.useRealTimers();
Expand Down
23 changes: 21 additions & 2 deletions static/app/views/investigations/detail/runTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ import {formatElapsedDuration} from 'sentry/utils/duration/formatElapsedDuration
import {isOnlyNextStepPending} from 'sentry/views/investigations/statusBlock/getSeerStatusBlock';
import type {InvestigationOrchestration} from 'sentry/views/investigations/types';

/**
* How long the run has been working, in seconds, as of the projection's
* snapshot, and whether that clock is still running. `null` when the
* projection can't vouch for a value.
*
* The seconds are measured on the server's clock, so a caller that keeps
* counting should add only time it measured itself since the snapshot landed,
* never the gap between `serverTime` and its own wall clock.
*/
export function getInvestigationActiveTime(
orchestration: InvestigationOrchestration
): {active: boolean; seconds: number} | null {
const settled = isOnlyNextStepPending(orchestration);
const active = !settled && ['processing', 'pending'].includes(orchestration.status);
const seconds = getActiveSeconds(orchestration, settled, active);
return seconds === null ? null : {active, seconds};
}

export function InvestigationRunTimer({
orchestration,
}: {
Expand All @@ -18,8 +36,9 @@ export function InvestigationRunTimer({
} | null>(null);
const runKey = `${orchestration.runId}:${orchestration.generation}`;
const settled = isOnlyNextStepPending(orchestration);
const active = !settled && ['processing', 'pending'].includes(orchestration.status);
let seconds = getActiveSeconds(orchestration, settled, active);
const activeTime = getInvestigationActiveTime(orchestration);
const active = activeTime?.active ?? false;
let seconds = activeTime?.seconds ?? null;

const frozenSeconds = settledSeconds?.runKey === runKey ? settledSeconds.seconds : null;
if (settled && seconds !== null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,11 @@ export function InvestigationHypotheses({
// rewrite the projection behind it.
const [commandSentAt, setCommandSentAt] = useState<number | null>(null);

const {data: projection, isPending} = useQuery({
const {
data: projection,
dataUpdatedAt,
isPending,
} = useQuery({
...investigationOrchestrationQueryOptions(organization.slug, investigationId),
enabled,
refetchInterval: query => {
Expand Down Expand Up @@ -227,7 +231,7 @@ export function InvestigationHypotheses({
// A finished run has nothing left to report here: the findings below speak
// for themselves, and the header badge still says it completed. Every other
// state — running, waiting on input, failed, stopped — keeps the block.
const seerStatus = getSeerStatusBlock(projection);
const seerStatus = getSeerStatusBlock(projection, dataUpdatedAt);
const statusBlock = seerStatus?.variant === 'complete' ? null : seerStatus;
const {workflowVersion} = projection;
const commandPending = commandMutation.isPending;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {t, tn} from 'sentry/locale';
import {getInvestigationActiveTime} from 'sentry/views/investigations/detail/runTimer';
import type {SeerStatusBlockVariant} from 'sentry/views/investigations/statusBlock/seerStatusBlock';
import type {
InvestigationOrchestration,
Expand All @@ -11,6 +12,7 @@ type SeerStatusBlockContent = {
variant: SeerStatusBlockVariant;
description?: string;
meta?: string;
startTime?: Date;
toolActivity?: InvestigationToolActivity[];
};

Expand Down Expand Up @@ -130,12 +132,34 @@ function getFailureMessage(projection: InvestigationOrchestration): string | und
* working and the viewer has nothing to do — so only the words change.
*/
function getRunningContent(
projection: InvestigationOrchestration
projection: InvestigationOrchestration,
receivedAt: number | undefined
): SeerStatusBlockContent {
return {...getRunningCopy(projection), toolActivity: getToolActivity(projection)};
return {
...getRunningCopy(projection),
toolActivity: getToolActivity(projection),
startTime: getStartTime(projection, receivedAt),
};
}

/** The words for a running run; the tool calls are added by the caller. */
/**
* When the run started working, on this browser's clock: its active time as of
* the snapshot, counted back from when the snapshot arrived. Only differences on
* each clock are used, so a browser whose clock disagrees with the server's
* still counts the same time as the server does.
*/
function getStartTime(
projection: InvestigationOrchestration,
receivedAt: number | undefined
): Date | undefined {
const activeTime = getInvestigationActiveTime(projection);
if (!activeTime || receivedAt === undefined) {
return undefined;
}
return new Date(receivedAt - activeTime.seconds * 1000);
}

/** The words for a running run; the tool calls and clock are added by the caller. */
function getRunningCopy(projection: InvestigationOrchestration): SeerStatusBlockContent {
const causeCount = projection.hypotheses.length;

Expand Down Expand Up @@ -235,9 +259,14 @@ function getCompleteContent(
* stopped run shares the `completed`/`failed`/`cancelled` phases with its
* status. Taking the variant from the status keeps the colour tied to whether
* the viewer has to do anything.
*
* `receivedAt` is when this browser received the projection (the query's
* `dataUpdatedAt`). A running block needs it to start its clock at the run's
* start; without it the clock starts when the block appears.
*/
export function getSeerStatusBlock(
projection: InvestigationOrchestration
projection: InvestigationOrchestration,
receivedAt?: number
): SeerStatusBlockContent | null {
switch (projection.status) {
// Stopped, but recoverably, and the only state that asks for something
Expand Down Expand Up @@ -273,13 +302,13 @@ export function getSeerStatusBlock(
case 'processing':
return isOnlyNextStepPending(projection)
? getCompleteContent(projection)
: getRunningContent(projection);
: getRunningContent(projection, receivedAt);
case 'pending':
return getRunningContent(projection);
return getRunningContent(projection, receivedAt);
default:
// An unrecognized status is still a run in progress as far as the viewer
// is concerned — Seer can add one before this code knows the name, and a
// missing block reads as "nothing is happening", which is worse.
return getRunningContent(projection);
return getRunningContent(projection, receivedAt);
}
}
Loading
Loading