Skip to content
Merged
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
99 changes: 3 additions & 96 deletions src/renderer/components/thread/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Surface } from "@heroui/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Trans } from "@lingui/react/macro";
import { useShallow } from "zustand/react/shallow";
import { isThreadTurnActive, type ProjectLocation, type Thread } from "@/shared/contracts";
import { resolveGrokSessionDir } from "@/shared/grokSessionMedia";
import { isHomeProjectId } from "@/shared/homeScope";
import { chatMessageSurfaceClass } from "./parts/items/chatMessageSurface";
import { readBridge } from "@/renderer/bridge";
import { useShimmerRef } from "@/renderer/thinkingAnimator";
import { useScrollFade } from "@/renderer/hooks/useScrollFade";
import { useThreadHasBackgroundActivity } from "@/renderer/hooks/uiSelectors";
import { useAppStore } from "@/renderer/state/appStore";
Expand All @@ -24,7 +21,6 @@ import {
import { useFileEditorStore } from "@/renderer/state/fileEditorStore";
import { useProjectRootNames } from "@/renderer/state/projectRootNamesStore";
import { useProjectTreeStore } from "@/renderer/state/projectTreeStore";
import { formatElapsed } from "@/renderer/utils/formatTime";
import {
buildFileEditorContext,
openFileInEditor,
Expand All @@ -34,6 +30,7 @@ import { showSubAgentPanel } from "@/renderer/actions/panelActions";
import { ChatFindBar, type ScrollToIndex } from "@/renderer/components/find/ChatFindBar";
import { ChatPaneActionsContext, type ChatPaneActions } from "./chatPaneActionsContext";
import { ChatScrollControls, type ChatScrollControlsHandle } from "./ChatScrollControls";
import { ChatTurnElapsedFooter, type TurnTiming } from "./ChatTurnElapsed";
import { selectVisibleThreadTimelineEntries, type ChatTimelineEntry } from "./chatPaneSelectors";
import { shouldMarkUserScrollIntentFromPointerTarget } from "./chatScrollGeometry";
import { normalizeChatProjectPath } from "./chatPathUtils";
Expand Down Expand Up @@ -369,7 +366,7 @@ export function ChatPane(props: ChatPaneProps) {
}
footer={
showTailLoader && tailTurn ? (
<ChatTailLoader turn={tailTurn} isPaused={isTurnPaused} />
<ChatTurnElapsedFooter turn={tailTurn} isPaused={isTurnPaused} />
) : null
}
onWheelCapture={(event) => {
Expand Down Expand Up @@ -457,11 +454,6 @@ export function ChatPane(props: ChatPaneProps) {
);
}

interface TurnTiming {
startedAt: number;
endedAt: number | null;
}

interface CompletedTurnTiming extends TurnTiming {
anchorItemId: string | null;
endedAt: number;
Expand Down Expand Up @@ -514,91 +506,6 @@ function selectMostRecentDisplayableCompletedTurn(
return null;
}

function ChatTailLoader({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) {
return (
<div className="mx-auto w-full max-w-[920px]">
<Surface variant="transparent" className={chatMessageSurfaceClass}>
<div className="inline-flex items-center gap-1.5 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
<WorkingFor turn={turn} isPaused={isPaused} />
</div>
</Surface>
</div>
);
}

/**
* Self-ticking elapsed-time label. While `turn.endedAt` is null, ticks every
* second as "Working for N"; once set, freezes as "Worked for N". When
* `isPaused` is true (e.g. the runtime is blocked on a user-input prompt) the
* counter freezes at its current value and the paused interval is excluded
* from the elapsed total once it resumes. Mutates `textContent` directly via
* a ref instead of calling `setState` so the per-second tick produces zero
* React commits — important while the rest of the chat is potentially
* streaming.
*/
function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) {
const { t } = useLingui();
const textRef = useRef<HTMLSpanElement>(null);
const pauseStateRef = useRef<{ accumulatedPauseMs: number; pausedSinceMs: number | null }>({
accumulatedPauseMs: 0,
pausedSinceMs: null,
});

useEffect(() => {
pauseStateRef.current = { accumulatedPauseMs: 0, pausedSinceMs: null };
}, [turn.startedAt, turn.endedAt]);

useEffect(() => {
const update = () => {
const node = textRef.current;
if (!node) return;
if (turn.endedAt !== null) {
const elapsedSeconds = Math.max(0, Math.floor((turn.endedAt - turn.startedAt) / 1000));
const elapsed = formatElapsed(elapsedSeconds);
const text = elapsedSeconds < 1 ? "" : t`Worked for ${elapsed}`;
node.textContent = text;
node.dataset.poracodeShimmerText = text;
return;
}
const pauseState = pauseStateRef.current;
const now = Date.now();
const currentPauseMs =
pauseState.pausedSinceMs !== null ? Math.max(0, now - pauseState.pausedSinceMs) : 0;
const elapsedMs = now - turn.startedAt - pauseState.accumulatedPauseMs - currentPauseMs;
const elapsedSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
const elapsed = formatElapsed(elapsedSeconds);
const text = elapsedSeconds < 1 ? "" : t`Working for ${elapsed}`;
node.textContent = text;
node.dataset.poracodeShimmerText = text;
};

if (isPaused) {
if (pauseStateRef.current.pausedSinceMs === null) {
pauseStateRef.current.pausedSinceMs = Date.now();
}
update();
return;
}

if (pauseStateRef.current.pausedSinceMs !== null) {
pauseStateRef.current.accumulatedPauseMs += Math.max(
0,
Date.now() - pauseStateRef.current.pausedSinceMs,
);
pauseStateRef.current.pausedSinceMs = null;
}
update();
if (turn.endedAt !== null) return;
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [turn.startedAt, turn.endedAt, isPaused, t]);

const isThinking = !isPaused && turn.endedAt === null;
useShimmerRef(textRef, isThinking);
const className = isThinking ? "poracode-thinking-text" : "text-muted";
return <span ref={textRef} className={className} aria-live="polite" />;
}

function isScrollNavigationKey(key: string): boolean {
return (
key === "ArrowUp" ||
Expand Down
102 changes: 102 additions & 0 deletions src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useEffect, useRef } from "react";
import { Surface } from "@heroui/react";
import { useLingui } from "@lingui/react/macro";
import { useShimmerRef } from "@/renderer/thinkingAnimator";
import { formatElapsed } from "@/renderer/utils/formatTime";
import { chatMessageSurfaceClass } from "./parts/items/chatMessageSurface";

export interface TurnTiming {
startedAt: number;
endedAt: number | null;
}

export function ChatTurnElapsedFooter({
turn,
isPaused = false,
}: {
turn: TurnTiming;
isPaused?: boolean;
}) {
return (
<div className="mx-auto w-full max-w-[920px]">
<Surface variant="transparent" className={chatMessageSurfaceClass}>
<div className="inline-flex items-center gap-1.5 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
<WorkingFor turn={turn} isPaused={isPaused} />
</div>
</Surface>
</div>
);
}

/**
* Self-ticking elapsed-time label. While `turn.endedAt` is null, ticks every
* second as "Working for N"; once set, freezes as "Worked for N". When
* `isPaused` is true (e.g. the runtime is blocked on a user-input prompt) the
* counter freezes at its current value and the paused interval is excluded
* from the elapsed total once it resumes. Mutates `textContent` directly via
* a ref instead of calling `setState` so the per-second tick produces zero
* React commits — important while the rest of the chat is potentially
* streaming.
*/
function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) {
const { t } = useLingui();
const textRef = useRef<HTMLSpanElement>(null);
const pauseStateRef = useRef<{ accumulatedPauseMs: number; pausedSinceMs: number | null }>({
accumulatedPauseMs: 0,
pausedSinceMs: null,
});

useEffect(() => {
pauseStateRef.current = { accumulatedPauseMs: 0, pausedSinceMs: null };
}, [turn.startedAt, turn.endedAt]);

useEffect(() => {
const update = () => {
const node = textRef.current;
if (!node) return;
if (turn.endedAt !== null) {
const elapsedSeconds = Math.max(0, Math.floor((turn.endedAt - turn.startedAt) / 1000));
const elapsed = formatElapsed(elapsedSeconds);
const text = elapsedSeconds < 1 ? "" : t`Worked for ${elapsed}`;
node.textContent = text;
node.dataset.poracodeShimmerText = text;
return;
}
const pauseState = pauseStateRef.current;
const now = Date.now();
const currentPauseMs =
pauseState.pausedSinceMs !== null ? Math.max(0, now - pauseState.pausedSinceMs) : 0;
const elapsedMs = now - turn.startedAt - pauseState.accumulatedPauseMs - currentPauseMs;
const elapsedSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
const elapsed = formatElapsed(elapsedSeconds);
const text = elapsedSeconds < 1 ? "" : t`Working for ${elapsed}`;
node.textContent = text;
node.dataset.poracodeShimmerText = text;
};

if (isPaused) {
if (pauseStateRef.current.pausedSinceMs === null) {
pauseStateRef.current.pausedSinceMs = Date.now();
}
update();
return;
}

if (pauseStateRef.current.pausedSinceMs !== null) {
pauseStateRef.current.accumulatedPauseMs += Math.max(
0,
Date.now() - pauseStateRef.current.pausedSinceMs,
);
pauseStateRef.current.pausedSinceMs = null;
}
update();
if (turn.endedAt !== null) return;
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [turn.startedAt, turn.endedAt, isPaused, t]);

const isThinking = !isPaused && turn.endedAt === null;
useShimmerRef(textRef, isThinking);
const className = isThinking ? "poracode-thinking-text" : "text-muted";
return <span ref={textRef} className={className} aria-live="polite" />;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { screen, waitFor, within } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectLocation, ToolCallPayload } from "@/shared/contracts";
import { AppProvider } from "@/renderer/components/ui/provider";
Expand All @@ -15,6 +15,7 @@ const mockBridge = {
subagentUnsubscribe:
vi.fn<(payload: { threadId: string; parentItemId: string }) => Promise<void>>(),
};
const mockScrollToEnd = vi.hoisted(() => vi.fn<() => Promise<void>>());

type MockLegendProps = {
data: readonly ChatTimelineEntry[];
Expand All @@ -26,6 +27,8 @@ type MockLegendProps = {
className?: string;
contentContainerClassName?: string;
contentContainerStyle?: React.CSSProperties;
style?: React.CSSProperties;
onWheelCapture?: React.WheelEventHandler<HTMLDivElement>;
onLoad?: () => void;
};

Expand All @@ -44,11 +47,17 @@ vi.mock("@legendapp/list/react", async () => {
sizes: new Map(),
listen: () => () => undefined,
}),
scrollToEnd: () => Promise.resolve(),
scrollToEnd: mockScrollToEnd,
}));
React.useLayoutEffect(() => onLoadRef.current?.(), []);
return (
<div ref={scrollRef} className={props.className} data-poracode-chat-scroller="true">
<div
ref={scrollRef}
className={props.className}
style={props.style}
data-poracode-chat-scroller="true"
onWheelCapture={props.onWheelCapture}
>
<div
className={`legend-list-content-container ${props.contentContainerClassName ?? ""}`}
style={props.contentContainerStyle}
Expand Down Expand Up @@ -77,6 +86,7 @@ describe("SubAgentContent", () => {
beforeEach(() => {
mockBridge.subagentSubscribe.mockReset().mockResolvedValue({ history: [] });
mockBridge.subagentUnsubscribe.mockReset().mockResolvedValue(undefined);
mockScrollToEnd.mockReset().mockResolvedValue(undefined);
useAppStore.setState({
runtimeItemIdsByThread: {},
runtimeItemsByIdByThread: {},
Expand Down Expand Up @@ -135,6 +145,78 @@ describe("SubAgentContent", () => {
expect(icons[1]).toHaveClass("size-3.5");
});

it("reuses the main chat fade, sticky-bottom controls, and live elapsed footer", async () => {
const threadId = "thread-1";
const parentItem: RuntimeChatItem = {
...makeSubAgentItem("parent-1"),
startedAt: Date.now() - 70_000,
};

useAppStore.setState({
runtimeItemIdsByThread: { [threadId]: [parentItem.id] },
runtimeItemsByIdByThread: {
[threadId]: {
[parentItem.id]: parentItem,
},
},
runtimeStructuralVersionByThread: { [threadId]: 1 },
});

const view = render(
<SubAgentContent threadId={threadId} parentItemId={parentItem.id} hideHeader />,
);

expect(await screen.findByText("Working for 1m 10s")).toBeInTheDocument();
await waitFor(() => expect(mockScrollToEnd).toHaveBeenCalled());
const scroller = view.container.querySelector<HTMLElement>(
'[data-poracode-chat-scroller="true"]',
);
expect(scroller).not.toBeNull();
expect(scroller?.style.maskImage).toContain("var(--top-fade-size");
const scrollButton = screen.getByRole("button", { name: "Scroll to bottom" });
expect(scrollButton).toHaveClass("opacity-0");

Object.defineProperties(scroller!, {
clientHeight: { configurable: true, value: 200 },
scrollHeight: { configurable: true, value: 1_000 },
});
fireEvent.wheel(scroller!, { deltaY: -10 });
scroller!.scrollTop = 100;
fireEvent.scroll(scroller!);

await waitFor(() => expect(scrollButton).toHaveClass("opacity-80"));
mockScrollToEnd.mockClear();
fireEvent.click(scrollButton);
await waitFor(() => expect(mockScrollToEnd).toHaveBeenCalled());
});

it("shows the frozen subagent duration after completion", async () => {
const threadId = "thread-1";
const startedAt = Date.now() - 75_000;
const runningParent = makeSubAgentItem("parent-1");
const parentItem: RuntimeChatItem = {
...runningParent,
state: "completed",
startedAt,
completedAt: startedAt + 75_000,
payload: { ...(runningParent.payload as ToolCallPayload), status: "success" },
};

useAppStore.setState({
runtimeItemIdsByThread: { [threadId]: [parentItem.id] },
runtimeItemsByIdByThread: {
[threadId]: {
[parentItem.id]: parentItem,
},
},
runtimeStructuralVersionByThread: { [threadId]: 1 },
});

render(<SubAgentContent threadId={threadId} parentItemId={parentItem.id} hideHeader />);

expect(await screen.findByText("Worked for 1m 15s")).toBeInTheDocument();
});

it("splits a Crossagent name and model selection into a two-line route header", () => {
const threadId = "thread-1";
const parentItem: RuntimeChatItem = {
Expand Down
Loading