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
3 changes: 3 additions & 0 deletions src/renderer/actions/threadActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ describe("threadActions", () => {
const reopened = useAppStore.getState().threads[0];
expect(reopened?.status).toBe("launching");
expect(reopened?.attention).toBe("none");
expect(useAppStore.getState().connectingThreadIds[thread.id]).toBeUndefined();
expect(useAppStore.getState().pendingThreadLaunches[thread.id]).toBe("");
});

Expand All @@ -389,6 +390,7 @@ describe("threadActions", () => {
const reopened = useAppStore.getState().threads[0];
expect(reopened?.status).toBe("idle");
expect(reopened?.attention).toBe("none");
expect(useAppStore.getState().connectingThreadIds[thread.id]).toEqual(expect.any(String));
expect(useAppStore.getState().pendingThreadLaunches[thread.id]).toBe("");
});

Expand All @@ -409,6 +411,7 @@ describe("threadActions", () => {

// Transport split is in performInitialThreadLaunch (remote client vs bridge).
expect(useAppStore.getState().threads[0]?.status).toBe("idle");
expect(useAppStore.getState().connectingThreadIds[thread.id]).toEqual(expect.any(String));
expect(useAppStore.getState().pendingThreadLaunches[thread.id]).toBe("");
});

Expand Down
1 change: 1 addition & 0 deletions src/renderer/actions/threadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export function reopenStoredThread(threadId: string): void {
...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}),
canResumeWithConfig: thread.canResumeWithConfig || thread.sessionRef !== undefined,
});
if (isGuiReconnect) store.beginThreadConnecting(thread.id);
});
// Local and remote share this queue; performInitialThreadLaunch picks the
// host (local bridge vs remote client.startThread).
Expand Down
15 changes: 15 additions & 0 deletions src/renderer/components/thread/ChatPane/ChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ describe("ChatPane", () => {
fileCheckpointsByThread: {},
fileCheckpointTurnsByThread: {},
provisioningWorktreeThreadIds: {},
connectingThreadIds: {},
}));
});

Expand Down Expand Up @@ -283,6 +284,20 @@ describe("ChatPane", () => {
expect(screen.queryByText("Creating worktree…")).not.toBeInTheDocument();
});

it("shows connecting without starting a working timer during GUI reconnect", () => {
const thread = { ...makeThread(), status: "idle" as const };
useAppStore.setState({
threads: [thread],
connectingThreadIds: { [thread.id]: "connection-1" },
});

renderChatPane(thread);

expect(screen.getByText("Connecting…")).toBeInTheDocument();
expect(screen.queryByText(/^Working for/)).not.toBeInTheDocument();
expect(screen.queryByText("No messages yet")).not.toBeInTheDocument();
});

it("loads the next persisted page when LegendList reaches the start", async () => {
const thread = makeThread();
seedAssistantMessage(thread.id, "Latest answer");
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/components/thread/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { ChatFindBar, type ScrollToIndex } from "@/renderer/components/find/Chat
import { ChatPaneActionsContext, type ChatPaneActions } from "./chatPaneActionsContext";
import { ChatScrollControls, type ChatScrollControlsHandle } from "./ChatScrollControls";
import {
ChatConnectingFooter,
ChatTurnElapsedFooter,
ChatWorktreeProvisioningFooter,
type TurnTiming,
Expand Down Expand Up @@ -293,6 +294,7 @@ export function ChatPane(props: ChatPaneProps) {
const isWorktreeProvisioning = useAppStore(
(s) => s.provisioningWorktreeThreadIds[threadId] === true && status === "launching",
);
const isConnecting = useAppStore((s) => s.connectingThreadIds[threadId] !== undefined);
// Detached background work keeps the thread doing real work after the
// foreground turn settles. Treat that as "still working" for the tail-loader
// timer (so it keeps ticking "Working for ...") without touching `status` -
Expand Down Expand Up @@ -333,7 +335,7 @@ export function ChatPane(props: ChatPaneProps) {
// request before that round-trip completes, leaving status stuck at
// `needs_approval` even though the user has already answered.
const isTurnPaused = hasOpenRuntimeRequest;
const showEmptyHint = isEmpty && !isLive;
const showEmptyHint = isEmpty && !isLive && !isConnecting;
// The tail loader displays the most recent completed turn's frozen elapsed
// time when the thread is idle and no newer timeline row exists. Once an
// optimistic next prompt is appended, keep the completed indicator inline at
Expand Down Expand Up @@ -399,6 +401,8 @@ export function ChatPane(props: ChatPaneProps) {
footer={
isWorktreeProvisioning ? (
<ChatWorktreeProvisioningFooter />
) : isConnecting ? (
<ChatConnectingFooter />
) : showTailLoader && tailTurn ? (
<ChatTurnElapsedFooter turn={tailTurn} isPaused={isTurnPaused} />
) : null
Expand Down Expand Up @@ -456,7 +460,7 @@ export function ChatPane(props: ChatPaneProps) {
layoutChangeToken={layoutChangeToken}
tailEntryId={timelineEntries.at(-1)?.id ?? null}
threadId={threadId}
tailLoaderVisible={isWorktreeProvisioning || showTailLoader}
tailLoaderVisible={isWorktreeProvisioning || isConnecting || showTailLoader}
initialScrollSettled={isInitialScrollSettled}
initialScrollRevealDelayMs={props.initialScrollRevealDelayMs ?? 0}
virtualScrollToBottomRef={virtualScrollToBottomRef}
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ export function ChatWorktreeProvisioningFooter() {
);
}

export function ChatConnectingFooter() {
const { t } = useLingui();
const textRef = useRef<HTMLSpanElement>(null);
const text = t`Connecting…`;
useShimmerRef(textRef, true);

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">
<span
ref={textRef}
className="poracode-thinking-text"
data-poracode-shimmer-text={text}
aria-live="polite"
>
{text}
</span>
</div>
</Surface>
</div>
);
}

function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) {
if (turn.endedAt !== null) {
return <WorkedFor startedAt={turn.startedAt} endedAt={turn.endedAt} />;
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/components/thread/ThreadComposerSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ describe("ThreadComposerSection", () => {
runtimeItemsByIdByThread: {},
runtimeRequestsByThread: {},
pendingSteerByThreadId: {},
connectingThreadIds: {},
pendingComposerFocusThreadId: null,
threadDraftContents: {},
provisioningWorktreeThreadIds: {},
Expand Down Expand Up @@ -920,6 +921,24 @@ describe("ThreadComposerSection", () => {
);
});

it("does not submit or steer while a stored GUI session is reconnecting", async () => {
useAppStore.setState({ connectingThreadIds: { [guiThread.id]: "connection-1" } });
const onSubmitInput = vi
.fn<(prompt: string, segments?: unknown) => Promise<void>>()
.mockResolvedValue(undefined);
renderComposer({ onSubmitInput });

const input = screen.getByRole("textbox");
input.appendChild(document.createTextNode("wait for connection"));
fireEvent.input(input);
fireEvent.click(screen.getByText("send"));
await act(async () => Promise.resolve());

expect(onSubmitInput).not.toHaveBeenCalled();
expect(bridgeMock.setPendingSteer).not.toHaveBeenCalled();
expect(input).toHaveTextContent("wait for connection");
});

it("restores approval requests and composer text when auto-deny before submit fails", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
runtimeActions.resolveThreadServerRequest.mockRejectedValueOnce(new Error("deny failed"));
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/components/thread/ThreadComposerSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
(state) =>
state.provisioningWorktreeThreadIds[thread.id] === true && thread.status === "launching",
);
const isConnecting = useAppStore((state) => state.connectingThreadIds[thread.id] !== undefined);
const { t } = useLingui();
const [prompt, setPrompt] = useState("");
const [hasContent, setHasContent] = useState(false);
Expand Down Expand Up @@ -352,6 +353,7 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
thread.status === "working";
const canSubmitServerInput =
isServerControlled &&
!isConnecting &&
thread.sessionRef !== undefined &&
(thread.status === "idle" ||
thread.status === "needs_reply" ||
Expand Down Expand Up @@ -428,7 +430,8 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
const canInterruptStructuredTurn = canShowRuntimeChrome && thread.status === "working";
const pendingSteer = useAppStore((s) => s.pendingSteerByThreadId[thread.id]);
const visiblePendingSteer = useDelayedPendingSteer(pendingSteer);
const usesPendingSteerPath = !usesTerminalPresentation && thread.status === "working";
const usesPendingSteerPath =
!isConnecting && !usesTerminalPresentation && thread.status === "working";
const runtimeRequests = useAppStore((s) => s.runtimeRequestsByThread[thread.id]);
const activeRuntimeRequest = canShowRuntimeChrome ? runtimeRequests?.[0] : undefined;
const approvalDenyOption = activeRuntimeRequest
Expand Down
21 changes: 21 additions & 0 deletions src/renderer/components/thread/ThreadHeaderStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Thread } from "@/shared/contracts";
import { useAppStore } from "@/renderer/state/appStore";
import { renderWithI18n as render } from "@/renderer/testUtils/i18n";
import { ThreadHeaderStatusButton } from "./ThreadHeaderStatus";

Expand Down Expand Up @@ -50,6 +51,7 @@ function makeThread(status: "idle" | "finished"): Thread {

describe("ThreadHeaderStatusButton", () => {
beforeEach(() => {
useAppStore.setState({ connectingThreadIds: {} });
useThreadHasBackgroundActivityMock.mockReset();
useThreadHasBackgroundActivityMock.mockReturnValue(true);
});
Expand All @@ -75,4 +77,23 @@ describe("ThreadHeaderStatusButton", () => {
expect(useThreadHasBackgroundActivityMock).toHaveBeenCalledOnce();
},
);

it("shows connecting while a stored GUI session reconnects", () => {
const thread = makeThread("idle");
useAppStore.setState({ connectingThreadIds: { [thread.id]: "connection-1" } });

const { getByRole, getByText } = render(
<ThreadHeaderStatusButton
threadId={thread.id}
fallbackThread={thread}
fallbackAgentKind="claude"
agentLabel="Claude"
/>,
);

expect(
getByRole("button", { name: "Claude: Connecting…. Hover for status details." }),
).toBeInTheDocument();
expect(getByText("Connecting…")).toBeInTheDocument();
});
});
23 changes: 18 additions & 5 deletions src/renderer/components/thread/ThreadHeaderStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import type { Thread, ThreadStatusSource } from "@/shared/contracts";
import { ProviderIcon } from "@/renderer/components/providers/ProviderIcon";
import { getStatusTone } from "@/renderer/components/providers/statusTone";
import { useThreadHasBackgroundActivity } from "@/renderer/hooks/uiSelectors";
import { useAppStore } from "@/renderer/state/appStore";
import { useThread } from "@/renderer/state/useThread";
import type { TranslateFn } from "@/renderer/i18n/i18n";

export function threadRuntimeStatusLabel(
thread: Thread,
t: TranslateFn,
opts?: { hasBackgroundActivity?: boolean },
opts?: { hasBackgroundActivity?: boolean; isConnecting?: boolean },
): string {
if (opts?.isConnecting) return t(msg`Connecting…`);
const { status, attention } = thread;
if (status === "launching") return t(msg`Launching…`);
if (status === "inactive") return t(msg`Inactive`);
Expand Down Expand Up @@ -71,10 +73,14 @@ function ThreadStatusSupportDetail({ source }: { source: ThreadStatusSource | un
}
}

function ThreadHeaderStatusTooltipBody(props: { thread: Thread; hasBackgroundActivity: boolean }) {
const { thread, hasBackgroundActivity } = props;
function ThreadHeaderStatusTooltipBody(props: {
thread: Thread;
hasBackgroundActivity: boolean;
isConnecting: boolean;
}) {
const { thread, hasBackgroundActivity, isConnecting } = props;
const { t } = useLingui();
const runtime = threadRuntimeStatusLabel(thread, t, { hasBackgroundActivity });
const runtime = threadRuntimeStatusLabel(thread, t, { hasBackgroundActivity, isConnecting });
const source = thread.threadStatusSource;
const isServer = source === "server";
const errorMessage = thread.status === "error" ? thread.errorMessage?.trim() : undefined;
Expand Down Expand Up @@ -124,8 +130,14 @@ export function ThreadHeaderStatusButton(props: {
const { t } = useLingui();
const thread = useThread(props.threadId) ?? props.fallbackThread;
const hasBackgroundActivity = useThreadHasBackgroundActivity(props.threadId);
const isConnecting = useAppStore(
(state) => state.connectingThreadIds[props.threadId] !== undefined,
);
const agentLabel = props.agentLabel ?? props.fallbackAgentKind;
const statusLabel = threadRuntimeStatusLabel(thread, t, { hasBackgroundActivity });
const statusLabel = threadRuntimeStatusLabel(thread, t, {
hasBackgroundActivity,
isConnecting,
});

return (
<Tooltip delay={0}>
Expand Down Expand Up @@ -156,6 +168,7 @@ export function ThreadHeaderStatusButton(props: {
<ThreadHeaderStatusTooltipBody
thread={thread}
hasBackgroundActivity={hasBackgroundActivity}
isConnecting={isConnecting}
/>
</Tooltip.Content>
</Tooltip>
Expand Down
59 changes: 59 additions & 0 deletions src/renderer/components/thread/ThreadView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe("ThreadView", () => {
runtimeItemsByIdByThread: {},
runtimeRequestsByThread: {},
provisioningWorktreeThreadIds: {},
connectingThreadIds: {},
});
});

Expand Down Expand Up @@ -351,6 +352,64 @@ describe("ThreadView", () => {
await waitFor(() => expect(bridge.startThread).toHaveBeenCalled());
});

it("clears the renderer reconnect flag after a stored GUI session connects", async () => {
const thread: Thread = {
id: "thread-gui-reconnect",
projectId: "project-1",
title: "Reconnecting chat thread",
agentKind: "codex",
config: { model: "gpt-5.4" },
status: "idle",
attention: "none",
canResumeWithConfig: true,
sessionRef: {
providerSessionId: "session-gui-reconnect",
discoveredAt: new Date().toISOString(),
},
archived: false,
done: false,
starred: false,
presentationMode: "gui",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
useAppStore.setState({
threads: [thread],
connectingThreadIds: { [thread.id]: "connection-1" },
});

renderThreadView({
thread,
agentStatus: {
kind: "codex",
label: "Codex",
installed: true,
authState: "authenticated",
capabilities: {
models: [{ id: "gpt-5.4", label: "5.4" }],
efforts: ["low"],
modelEfforts: {},
modes: ["agent"],
approvalPolicies: [{ id: "on-request", label: "On Request" }],
sandboxModes: [{ id: "read-only", label: "Read Only" }],
supportsResume: true,
supportsDirectInput: true,
liveInputMode: "server",
presentationMode: "gui",
settingDefs: [],
},
},
projectLocation: { kind: "windows", path: "C:\\repo" },
pendingLaunchPrompt: "",
onLaunchConsumed: () => undefined,
});

await waitFor(() => expect(bridge.startThread).toHaveBeenCalled());
await waitFor(() => {
expect(useAppStore.getState().connectingThreadIds[thread.id]).toBeUndefined();
});
});

it("forwards launch rejection messages to the launch failure callback", async () => {
bridge.startThread.mockRejectedValueOnce(new Error("launcher boom"));
const onLaunchConsumed = vi.fn<() => void>();
Expand Down
15 changes: 11 additions & 4 deletions src/renderer/components/thread/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {

launchRequestRef.current = launchKey;
onLaunchConsumed?.();
const connectionToken = useAppStore.getState().connectingThreadIds[thread.id];

void (async () => {
await performInitialThreadLaunch({
Expand All @@ -247,10 +248,16 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
: {}),
initialSize: launchTerminalSize,
});
})().catch((error) => {
launchRequestRef.current = null;
onLaunchFailed?.(formatLaunchError(error, t`Thread failed to start.`));
});
})()
.catch((error) => {
launchRequestRef.current = null;
onLaunchFailed?.(formatLaunchError(error, t`Thread failed to start.`));
})
.finally(() => {
if (connectionToken) {
useAppStore.getState().finishThreadConnecting(thread.id, connectionToken);
}
});
}, [
t,
onLaunchConsumed,
Expand Down
Loading