From 5bf11eac5517894c0b04e6f89eca6550f7718a76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 7 May 2026 17:50:44 +0000 Subject: [PATCH 1/6] Reduce timeline active-turn rerenders Co-authored-by: Julius Marminge --- .../chat/MessagesTimeline.logic.test.ts | 75 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 11 ++- .../src/components/chat/MessagesTimeline.tsx | 17 ++--- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 633fb5d6bef..2a79e457e71 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -252,6 +252,8 @@ describe("deriveMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: "assistant-final-entry", isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), @@ -309,6 +311,8 @@ describe("deriveMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: null, isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map([ ["assistant-1" as never, assistantTurnDiffSummary], @@ -366,6 +370,8 @@ describe("computeStableMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: null, isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), @@ -382,6 +388,71 @@ describe("computeStableMessagesTimelineRows", () => { expect(repeated.result).toBe(initial.result); }); + it("keeps inactive assistant rows stable when the active turn changes", () => { + const firstAssistantMessage = { + id: "assistant-1" as never, + role: "assistant" as const, + text: "First", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:01Z", + streaming: false, + }; + const secondAssistantMessage = { + id: "assistant-2" as never, + role: "assistant" as const, + text: "Second", + turnId: "turn-2" as never, + createdAt: "2026-01-01T00:00:10Z", + completedAt: "2026-01-01T00:00:11Z", + streaming: false, + }; + const timelineEntries = [ + { + id: "entry-assistant-1", + kind: "message" as const, + createdAt: firstAssistantMessage.createdAt, + message: firstAssistantMessage, + }, + { + id: "entry-assistant-2", + kind: "message" as const, + createdAt: secondAssistantMessage.createdAt, + message: secondAssistantMessage, + }, + ]; + + const inactiveRows = deriveMessagesTimelineRows({ + timelineEntries, + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + const initial = computeStableMessagesTimelineRows(inactiveRows, { + byId: new Map(), + result: [], + }); + + const activeRows = deriveMessagesTimelineRows({ + timelineEntries, + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnInProgress: true, + activeTurnId: "turn-2" as never, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + const repeated = computeStableMessagesTimelineRows(activeRows, initial); + + expect(repeated.result[0]).toBe(initial.result[0]); + expect(repeated.result[1]).not.toBe(initial.result[1]); + }); + it("reuses work rows when equivalent timeline derivations create new grouped arrays", () => { const firstWorkEntry = { id: "work-1", @@ -416,6 +487,8 @@ describe("computeStableMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: null, isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), @@ -471,6 +544,8 @@ describe("computeStableMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: null, isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c99f0b98a66..5256aaa13ee 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,7 @@ import * as Equal from "effect/Equal"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { type MessageId } from "@t3tools/contracts"; +import { type MessageId, type TurnId } from "@t3tools/contracts"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; @@ -27,6 +27,7 @@ export type MessagesTimelineRow = durationStart: string; showCompletionDivider: boolean; showAssistantCopyButton: boolean; + assistantTurnStillInProgress: boolean; assistantTurnDiffSummary?: TurnDiffSummary | undefined; revertTurnCount?: number | undefined; } @@ -112,6 +113,8 @@ export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; completionDividerBeforeEntryId: string | null; isWorking: boolean; + activeTurnInProgress: boolean; + activeTurnId: TurnId | null | undefined; activeTurnStartedAt: string | null; turnDiffSummaryByAssistantMessageId: ReadonlyMap; revertTurnCountByUserMessageId: ReadonlyMap; @@ -170,6 +173,11 @@ export function deriveMessagesTimelineRows(input: { showAssistantCopyButton: timelineEntry.message.role === "assistant" && terminalAssistantMessageIds.has(timelineEntry.message.id), + assistantTurnStillInProgress: + timelineEntry.message.role === "assistant" && + input.activeTurnInProgress && + input.activeTurnId != null && + timelineEntry.message.turnId === input.activeTurnId, assistantTurnDiffSummary: timelineEntry.message.role === "assistant" ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) @@ -233,6 +241,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.durationStart === bm.durationStart && a.showCompletionDivider === bm.showCompletionDivider && a.showAssistantCopyButton === bm.showAssistantCopyButton && + a.assistantTurnStillInProgress === bm.assistantTurnStillInProgress && a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && a.revertTurnCount === bm.revertTurnCount ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index dafa8161bd2..c44dabe68b7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -82,8 +82,6 @@ interface TimelineRowSharedState { } interface TimelineRowActivityState { - activeTurnInProgress: boolean; - activeTurnId: TurnId | null; isWorking: boolean; isRevertingCheckpoint: boolean; completionSummary: string | null; @@ -155,6 +153,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, completionDividerBeforeEntryId, isWorking, + activeTurnInProgress, + activeTurnId, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, @@ -163,6 +163,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, completionDividerBeforeEntryId, isWorking, + activeTurnInProgress, + activeTurnId, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, @@ -221,13 +223,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const activityState = useMemo( () => ({ - activeTurnInProgress, - activeTurnId: activeTurnId ?? null, isWorking, isRevertingCheckpoint, completionSummary, }), - [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking], + [completionSummary, isRevertingCheckpoint, isWorking], ); // Stable renderItem — no closure deps. Row components read shared state @@ -450,15 +450,10 @@ function AssistantCompletionDivider() { } function AssistantCopyButton({ row }: { row: Extract }) { - const activity = use(TimelineRowActivityCtx); - const assistantTurnStillInProgress = - activity.activeTurnInProgress && - activity.activeTurnId !== null && - row.message.turnId === activity.activeTurnId; const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, - streaming: row.message.streaming || assistantTurnStillInProgress, + streaming: row.message.streaming || row.assistantTurnStillInProgress, }); if (!assistantCopyState.visible) { From f37bfbdc561422b7487dd7f3acb7d61cea8894b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 7 May 2026 17:52:50 +0000 Subject: [PATCH 2/6] Revert "Reduce timeline active-turn rerenders" This reverts commit 5bf11eac5517894c0b04e6f89eca6550f7718a76. --- .../chat/MessagesTimeline.logic.test.ts | 75 ------------------- .../components/chat/MessagesTimeline.logic.ts | 11 +-- .../src/components/chat/MessagesTimeline.tsx | 17 +++-- 3 files changed, 12 insertions(+), 91 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 2a79e457e71..633fb5d6bef 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -252,8 +252,6 @@ describe("deriveMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: "assistant-final-entry", isWorking: false, - activeTurnInProgress: false, - activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), @@ -311,8 +309,6 @@ describe("deriveMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: null, isWorking: false, - activeTurnInProgress: false, - activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map([ ["assistant-1" as never, assistantTurnDiffSummary], @@ -370,8 +366,6 @@ describe("computeStableMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: null, isWorking: false, - activeTurnInProgress: false, - activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), @@ -388,71 +382,6 @@ describe("computeStableMessagesTimelineRows", () => { expect(repeated.result).toBe(initial.result); }); - it("keeps inactive assistant rows stable when the active turn changes", () => { - const firstAssistantMessage = { - id: "assistant-1" as never, - role: "assistant" as const, - text: "First", - turnId: "turn-1" as never, - createdAt: "2026-01-01T00:00:00Z", - completedAt: "2026-01-01T00:00:01Z", - streaming: false, - }; - const secondAssistantMessage = { - id: "assistant-2" as never, - role: "assistant" as const, - text: "Second", - turnId: "turn-2" as never, - createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", - streaming: false, - }; - const timelineEntries = [ - { - id: "entry-assistant-1", - kind: "message" as const, - createdAt: firstAssistantMessage.createdAt, - message: firstAssistantMessage, - }, - { - id: "entry-assistant-2", - kind: "message" as const, - createdAt: secondAssistantMessage.createdAt, - message: secondAssistantMessage, - }, - ]; - - const inactiveRows = deriveMessagesTimelineRows({ - timelineEntries, - completionDividerBeforeEntryId: null, - isWorking: false, - activeTurnInProgress: false, - activeTurnId: null, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - const initial = computeStableMessagesTimelineRows(inactiveRows, { - byId: new Map(), - result: [], - }); - - const activeRows = deriveMessagesTimelineRows({ - timelineEntries, - completionDividerBeforeEntryId: null, - isWorking: false, - activeTurnInProgress: true, - activeTurnId: "turn-2" as never, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - const repeated = computeStableMessagesTimelineRows(activeRows, initial); - - expect(repeated.result[0]).toBe(initial.result[0]); - expect(repeated.result[1]).not.toBe(initial.result[1]); - }); - it("reuses work rows when equivalent timeline derivations create new grouped arrays", () => { const firstWorkEntry = { id: "work-1", @@ -487,8 +416,6 @@ describe("computeStableMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: null, isWorking: false, - activeTurnInProgress: false, - activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), @@ -544,8 +471,6 @@ describe("computeStableMessagesTimelineRows", () => { ], completionDividerBeforeEntryId: null, isWorking: false, - activeTurnInProgress: false, - activeTurnId: null, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5256aaa13ee..c99f0b98a66 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,7 @@ import * as Equal from "effect/Equal"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { type MessageId } from "@t3tools/contracts"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; @@ -27,7 +27,6 @@ export type MessagesTimelineRow = durationStart: string; showCompletionDivider: boolean; showAssistantCopyButton: boolean; - assistantTurnStillInProgress: boolean; assistantTurnDiffSummary?: TurnDiffSummary | undefined; revertTurnCount?: number | undefined; } @@ -113,8 +112,6 @@ export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; completionDividerBeforeEntryId: string | null; isWorking: boolean; - activeTurnInProgress: boolean; - activeTurnId: TurnId | null | undefined; activeTurnStartedAt: string | null; turnDiffSummaryByAssistantMessageId: ReadonlyMap; revertTurnCountByUserMessageId: ReadonlyMap; @@ -173,11 +170,6 @@ export function deriveMessagesTimelineRows(input: { showAssistantCopyButton: timelineEntry.message.role === "assistant" && terminalAssistantMessageIds.has(timelineEntry.message.id), - assistantTurnStillInProgress: - timelineEntry.message.role === "assistant" && - input.activeTurnInProgress && - input.activeTurnId != null && - timelineEntry.message.turnId === input.activeTurnId, assistantTurnDiffSummary: timelineEntry.message.role === "assistant" ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) @@ -241,7 +233,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.durationStart === bm.durationStart && a.showCompletionDivider === bm.showCompletionDivider && a.showAssistantCopyButton === bm.showAssistantCopyButton && - a.assistantTurnStillInProgress === bm.assistantTurnStillInProgress && a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && a.revertTurnCount === bm.revertTurnCount ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c44dabe68b7..dafa8161bd2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -82,6 +82,8 @@ interface TimelineRowSharedState { } interface TimelineRowActivityState { + activeTurnInProgress: boolean; + activeTurnId: TurnId | null; isWorking: boolean; isRevertingCheckpoint: boolean; completionSummary: string | null; @@ -153,8 +155,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, completionDividerBeforeEntryId, isWorking, - activeTurnInProgress, - activeTurnId, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, @@ -163,8 +163,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, completionDividerBeforeEntryId, isWorking, - activeTurnInProgress, - activeTurnId, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, @@ -223,11 +221,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const activityState = useMemo( () => ({ + activeTurnInProgress, + activeTurnId: activeTurnId ?? null, isWorking, isRevertingCheckpoint, completionSummary, }), - [completionSummary, isRevertingCheckpoint, isWorking], + [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking], ); // Stable renderItem — no closure deps. Row components read shared state @@ -450,10 +450,15 @@ function AssistantCompletionDivider() { } function AssistantCopyButton({ row }: { row: Extract }) { + const activity = use(TimelineRowActivityCtx); + const assistantTurnStillInProgress = + activity.activeTurnInProgress && + activity.activeTurnId !== null && + row.message.turnId === activity.activeTurnId; const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, - streaming: row.message.streaming || row.assistantTurnStillInProgress, + streaming: row.message.streaming || assistantTurnStillInProgress, }); if (!assistantCopyState.visible) { From a811a7cd0e0c007a42764c78bee199c971ecefbc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 7 May 2026 17:54:11 +0000 Subject: [PATCH 3/6] Avoid extra SSH prompt expiry render Co-authored-by: Julius Marminge --- .../desktop/SshPasswordPromptDialog.tsx | 72 +++++++++++++------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index 7a20badf02b..2d5277a22f9 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -1,5 +1,5 @@ import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { Button } from "../ui/button"; import { @@ -28,16 +28,10 @@ function getPromptErrorMessage(error: unknown): string { : message; } -export function SshPasswordPromptDialog() { +const EXPIRED_PROMPT_MESSAGE = "This SSH password prompt expired. Try connecting again."; + +function useSshPasswordPromptQueue() { const [queue, setQueue] = useState([]); - const [password, setPassword] = useState(""); - const [isResponding, setIsResponding] = useState(false); - const [now, setNow] = useState(() => Date.now()); - const [responseError, setResponseError] = useState(null); - const currentRequest = queue[0] ?? null; - const inputRef = useRef(null); - const isRespondingRef = useRef(false); - const formId = useId(); useEffect(() => { const bridge = window.desktopBridge; @@ -50,14 +44,29 @@ export function SshPasswordPromptDialog() { }); }, []); + return [queue, setQueue] as const; +} + +function usePromptDraftReset( + currentRequest: DesktopSshPasswordPromptRequest | null, + setPassword: Dispatch>, + setResponseError: Dispatch>, +) { useEffect(() => { setPassword(""); setResponseError(null); + }, [currentRequest, setPassword, setResponseError]); +} + +function usePromptInputAutoFocus( + currentRequest: DesktopSshPasswordPromptRequest | null, + inputRef: React.RefObject, +) { + useEffect(() => { if (!currentRequest) { return; } - setNow(Date.now()); const frame = window.requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); @@ -65,6 +74,18 @@ export function SshPasswordPromptDialog() { return () => { window.cancelAnimationFrame(frame); }; + }, [currentRequest, inputRef]); +} + +function usePromptNow(currentRequest: DesktopSshPasswordPromptRequest | null) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!currentRequest) { + return; + } + + setNow(Date.now()); }, [currentRequest]); useEffect(() => { @@ -80,18 +101,29 @@ export function SshPasswordPromptDialog() { }; }, [currentRequest]); + return now; +} + +export function SshPasswordPromptDialog() { + const [queue, setQueue] = useSshPasswordPromptQueue(); + const [password, setPassword] = useState(""); + const [isResponding, setIsResponding] = useState(false); + const [responseError, setResponseError] = useState(null); + const currentRequest = queue[0] ?? null; + const inputRef = useRef(null); + const isRespondingRef = useRef(false); + const formId = useId(); + const now = usePromptNow(currentRequest); + usePromptDraftReset(currentRequest, setPassword, setResponseError); + usePromptInputAutoFocus(currentRequest, inputRef); + const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN; const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; const isExpired = remainingMs !== null && remainingMs <= 0; const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); const remainingLabel = remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); - - useEffect(() => { - if (isExpired) { - setResponseError("This SSH password prompt expired. Try connecting again."); - } - }, [isExpired]); + const visibleResponseError = isExpired ? EXPIRED_PROMPT_MESSAGE : responseError; const removeCurrentPrompt = (requestId: string) => { setQueue((currentQueue) => @@ -108,7 +140,7 @@ export function SshPasswordPromptDialog() { const requestId = currentRequest.requestId; if (nextPassword !== null && isExpired) { - setResponseError("This SSH password prompt expired. Try connecting again."); + setResponseError(EXPIRED_PROMPT_MESSAGE); return; } @@ -198,8 +230,8 @@ export function SshPasswordPromptDialog() { onChange={(event) => setPassword(event.target.value)} /> - {responseError ? ( -

{responseError}

+ {visibleResponseError ? ( +

{visibleResponseError}

) : (

Use SSH keys to avoid repeated password prompts on new SSH sessions. From e06925cf872d587bfcef0c47a02064315d7f6f14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 7 May 2026 17:54:44 +0000 Subject: [PATCH 4/6] Keep SSH prompt lifecycle effects batched Co-authored-by: Julius Marminge --- .../desktop/SshPasswordPromptDialog.tsx | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index 2d5277a22f9..3d0fe97d495 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -1,5 +1,13 @@ import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; -import { useEffect, useId, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { + useEffect, + useId, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction, +} from "react"; import { Button } from "../ui/button"; import { @@ -47,26 +55,22 @@ function useSshPasswordPromptQueue() { return [queue, setQueue] as const; } -function usePromptDraftReset( +function useCurrentPromptLifecycle( currentRequest: DesktopSshPasswordPromptRequest | null, + inputRef: RefObject, setPassword: Dispatch>, setResponseError: Dispatch>, ) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { setPassword(""); setResponseError(null); - }, [currentRequest, setPassword, setResponseError]); -} - -function usePromptInputAutoFocus( - currentRequest: DesktopSshPasswordPromptRequest | null, - inputRef: React.RefObject, -) { - useEffect(() => { if (!currentRequest) { return; } + setNow(Date.now()); const frame = window.requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); @@ -74,19 +78,7 @@ function usePromptInputAutoFocus( return () => { window.cancelAnimationFrame(frame); }; - }, [currentRequest, inputRef]); -} - -function usePromptNow(currentRequest: DesktopSshPasswordPromptRequest | null) { - const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - if (!currentRequest) { - return; - } - - setNow(Date.now()); - }, [currentRequest]); + }, [currentRequest, inputRef, setPassword, setResponseError]); useEffect(() => { if (!currentRequest) { @@ -113,9 +105,7 @@ export function SshPasswordPromptDialog() { const inputRef = useRef(null); const isRespondingRef = useRef(false); const formId = useId(); - const now = usePromptNow(currentRequest); - usePromptDraftReset(currentRequest, setPassword, setResponseError); - usePromptInputAutoFocus(currentRequest, inputRef); + const now = useCurrentPromptLifecycle(currentRequest, inputRef, setPassword, setResponseError); const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN; const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; From 7ead5c71eb7d962de49fb9c6bfeb6500e184ab8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 7 May 2026 17:56:22 +0000 Subject: [PATCH 5/6] Use reducer for SSH prompt draft state Co-authored-by: Julius Marminge --- .../desktop/SshPasswordPromptDialog.tsx | 83 +++++++++++++------ 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index 3d0fe97d495..9a5497904e2 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -2,11 +2,11 @@ import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; import { useEffect, useId, + useReducer, useRef, useState, type Dispatch, type RefObject, - type SetStateAction, } from "react"; import { Button } from "../ui/button"; @@ -38,6 +38,40 @@ function getPromptErrorMessage(error: unknown): string { const EXPIRED_PROMPT_MESSAGE = "This SSH password prompt expired. Try connecting again."; +type PromptDraftState = { + password: string; + responseError: string | null; + now: number; +}; + +type PromptDraftAction = + | { type: "prompt.reset"; now: number } + | { type: "clock.tick"; now: number } + | { type: "password.set"; password: string } + | { type: "error.set"; error: string | null }; + +function promptDraftReducer( + state: PromptDraftState, + action: PromptDraftAction, +): PromptDraftState { + switch (action.type) { + case "prompt.reset": + return { + password: "", + responseError: null, + now: action.now, + }; + case "clock.tick": + return state.now === action.now ? state : { ...state, now: action.now }; + case "password.set": + return state.password === action.password ? state : { ...state, password: action.password }; + case "error.set": + return state.responseError === action.error + ? state + : { ...state, responseError: action.error }; + } +} + function useSshPasswordPromptQueue() { const [queue, setQueue] = useState([]); @@ -58,19 +92,14 @@ function useSshPasswordPromptQueue() { function useCurrentPromptLifecycle( currentRequest: DesktopSshPasswordPromptRequest | null, inputRef: RefObject, - setPassword: Dispatch>, - setResponseError: Dispatch>, + dispatch: Dispatch, ) { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - setPassword(""); - setResponseError(null); + dispatch({ type: "prompt.reset", now: Date.now() }); if (!currentRequest) { return; } - setNow(Date.now()); const frame = window.requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); @@ -78,7 +107,7 @@ function useCurrentPromptLifecycle( return () => { window.cancelAnimationFrame(frame); }; - }, [currentRequest, inputRef, setPassword, setResponseError]); + }, [currentRequest, dispatch, inputRef]); useEffect(() => { if (!currentRequest) { @@ -86,41 +115,41 @@ function useCurrentPromptLifecycle( } const interval = window.setInterval(() => { - setNow(Date.now()); + dispatch({ type: "clock.tick", now: Date.now() }); }, 1_000); return () => { window.clearInterval(interval); }; - }, [currentRequest]); - - return now; + }, [currentRequest, dispatch]); } export function SshPasswordPromptDialog() { const [queue, setQueue] = useSshPasswordPromptQueue(); - const [password, setPassword] = useState(""); const [isResponding, setIsResponding] = useState(false); - const [responseError, setResponseError] = useState(null); + const [draft, dispatchDraft] = useReducer(promptDraftReducer, undefined, () => ({ + password: "", + responseError: null, + now: Date.now(), + })); const currentRequest = queue[0] ?? null; const inputRef = useRef(null); const isRespondingRef = useRef(false); const formId = useId(); - const now = useCurrentPromptLifecycle(currentRequest, inputRef, setPassword, setResponseError); + useCurrentPromptLifecycle(currentRequest, inputRef, dispatchDraft); const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN; - const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; + const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - draft.now) : null; const isExpired = remainingMs !== null && remainingMs <= 0; const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); const remainingLabel = remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); - const visibleResponseError = isExpired ? EXPIRED_PROMPT_MESSAGE : responseError; + const visibleResponseError = isExpired ? EXPIRED_PROMPT_MESSAGE : draft.responseError; const removeCurrentPrompt = (requestId: string) => { setQueue((currentQueue) => currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, ); - setPassword(""); - setResponseError(null); + dispatchDraft({ type: "prompt.reset", now: Date.now() }); }; const respond = async (nextPassword: string | null) => { @@ -130,13 +159,13 @@ export function SshPasswordPromptDialog() { const requestId = currentRequest.requestId; if (nextPassword !== null && isExpired) { - setResponseError(EXPIRED_PROMPT_MESSAGE); + dispatchDraft({ type: "error.set", error: EXPIRED_PROMPT_MESSAGE }); return; } isRespondingRef.current = true; setIsResponding(true); - setResponseError(null); + dispatchDraft({ type: "error.set", error: null }); try { await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); removeCurrentPrompt(requestId); @@ -144,7 +173,7 @@ export function SshPasswordPromptDialog() { if (nextPassword === null) { removeCurrentPrompt(requestId); } else { - setResponseError(getPromptErrorMessage(error)); + dispatchDraft({ type: "error.set", error: getPromptErrorMessage(error) }); } } finally { isRespondingRef.current = false; @@ -192,7 +221,7 @@ export function SshPasswordPromptDialog() { id={formId} onSubmit={(event) => { event.preventDefault(); - void respond(password); + void respond(draft.password); }} >

@@ -216,8 +245,10 @@ export function SshPasswordPromptDialog() { disabled={isResponding || isExpired} name="ssh-password" type="password" - value={password} - onChange={(event) => setPassword(event.target.value)} + value={draft.password} + onChange={(event) => + dispatchDraft({ type: "password.set", password: event.target.value }) + } />
{visibleResponseError ? ( From 6ff98af8e06331e5a74a7da029a3196147bfec2e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 7 May 2026 17:59:57 +0000 Subject: [PATCH 6/6] Format SSH prompt reducer state Co-authored-by: Julius Marminge --- apps/web/src/components/desktop/SshPasswordPromptDialog.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index 9a5497904e2..fbafc853943 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -50,10 +50,7 @@ type PromptDraftAction = | { type: "password.set"; password: string } | { type: "error.set"; error: string | null }; -function promptDraftReducer( - state: PromptDraftState, - action: PromptDraftAction, -): PromptDraftState { +function promptDraftReducer(state: PromptDraftState, action: PromptDraftAction): PromptDraftState { switch (action.type) { case "prompt.reset": return {