Files
openchamber/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx
T
Islam NoflandBohdan Triapitsyn 4523e9c486 perf: reduce re-renders, fix mobile keyboard handling, add chunk load recovery, and improve PATH management (#1028)
* fix: exclude file content from reverted prompt text

Revert and fork now restore only the user's original prompt, not server-injected file content
Uses existing isSyntheticPart helper for type-safe filtering

* fix: keep scrollbar visible when hovering over thumb

* fix: prevent ESC abort from triggering when terminal is focused

* fix: pass directory to permission/question reply calls so approvals actually resolve

* fix: default model selection not responding after Base UI migration

* fix: prevent modal content from shifting and clipping footer buttons

* fix: improve session switching performance and add sub-agent export with prompt collapse

Defer viewport anchor saving to eliminate ~800ms UI freeze when switching sessions
Add export dialog to include sub-agent tasks recursively in markdown export
Add collapse chevron button for expanded user prompts in sticky header

* fix: resolve sidebar scroll and TDZ crash in session sidebar

* perf: reduce CPU overhead and re-renders across chat, layout, and settings

* fix: position collapse button at top of message and prevent ESC abort in terminal

* fix: position collapse button at top and add padding only when expanded

* refactor: extract shared PATH utilities and mobile keyboard hook

* refactor: import shared path-utils in electron, use module-level style constants

- Electron now imports pathLooksUserConfigured/mergePathValues from
  shared path-utils.js instead of inline duplication
- ToolPart collapsedCustomStyle moved from useMemo([]) to module const

* fix: resolve remaining merge conflicts and type errors

- Remove duplicate variable declarations in SessionNodeItem
- Remove orphaned export callback body from conflict resolution
- Fix HelpDialog description -> descriptionKey (i18n rename)

* fix: resolve type-check and lint errors in session-actions.test.ts

- Added missing bun:test type declarations (beforeEach, mock, mock.module)
- Removed unused State import
- Replaced 'as any' casts with proper OpencodeClient and ChildStoreManager types
- Added eslint-disable for unused _ parameter in mock function

* fix PR 1028 export and PATH edge cases

* fix startup retry exhaustion state

* remove opencode package lock change

* fix sub-session rename cancellation

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-04-26 16:24:07 +03:00

233 lines
6.4 KiB
TypeScript

import React from 'react';
import { BusyDots } from './BusyDots';
interface WorkingPlaceholderProps {
isWorking: boolean;
statusText: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
agentName?: string;
}
const STATUS_DISPLAY_TIME_MS = 1200;
const EPOCH_SECONDS_THRESHOLD = 1_000_000_000;
const EPOCH_MILLISECONDS_THRESHOLD = 1_000_000_000_000;
const toRetryTargetTimestamp = (next: number): number => {
if (next >= EPOCH_MILLISECONDS_THRESHOLD) {
return next;
}
if (next >= EPOCH_SECONDS_THRESHOLD) {
return next * 1000;
}
return Date.now() + next;
};
const formatRetryCountdown = (seconds: number): string => {
if (seconds < 60) {
return `${seconds}s`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainderSeconds = seconds % 60;
return remainderSeconds > 0 ? `${minutes}m ${remainderSeconds}s` : `${minutes}m`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
const remainderMinutes = Math.floor((seconds % 3600) / 60);
return remainderMinutes > 0 ? `${hours}h ${remainderMinutes}m` : `${hours}h`;
}
const days = Math.floor(seconds / 86400);
const remainderHours = Math.floor((seconds % 86400) / 3600);
if (remainderHours > 0) {
return `${days}d ${remainderHours}h`;
}
return `${days}d`;
};
export function WorkingPlaceholder({
isWorking,
statusText,
isGenericStatus,
isWaitingForPermission,
retryInfo,
}: WorkingPlaceholderProps) {
const [displayedText, setDisplayedText] = React.useState<string | null>(null);
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
const displayedTextRef = React.useRef(displayedText);
const displayedPermissionRef = React.useRef(displayedPermission);
displayedTextRef.current = displayedText;
displayedPermissionRef.current = displayedPermission;
const statusShownAtRef = React.useRef<number>(0);
const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null);
const processQueueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Countdown state for retry mode
const [retryCountdown, setRetryCountdown] = React.useState<number | null>(null);
React.useEffect(() => {
const rawNext = retryInfo?.next;
if (!rawNext || rawNext <= 0) {
setRetryCountdown(null);
return;
}
const retryTargetAt = toRetryTargetTimestamp(rawNext);
const update = () => {
const remaining = Math.max(0, retryTargetAt - Date.now());
setRetryCountdown(Math.ceil(remaining / 1000));
};
update();
const id = setInterval(update, 500);
return () => clearInterval(id);
}, [retryInfo?.next, retryInfo?.attempt]);
const clearTimers = React.useCallback(() => {
if (processQueueTimerRef.current) {
clearTimeout(processQueueTimerRef.current);
processQueueTimerRef.current = null;
}
}, []);
const showStatus = React.useCallback((text: string, permission: boolean) => {
clearTimers();
queuedStatusRef.current = null;
setDisplayedText(text);
setDisplayedPermission(permission);
statusShownAtRef.current = Date.now();
}, [clearTimers]);
const scheduleQueueProcess = React.useCallback(() => {
if (processQueueTimerRef.current) return;
const elapsed = Date.now() - statusShownAtRef.current;
const remaining = Math.max(0, STATUS_DISPLAY_TIME_MS - elapsed);
processQueueTimerRef.current = setTimeout(() => {
processQueueTimerRef.current = null;
const queued = queuedStatusRef.current;
if (queued) {
showStatus(queued.text, queued.permission);
}
}, remaining);
}, [showStatus]);
React.useEffect(() => {
if (!isWorking) {
clearTimers();
queuedStatusRef.current = null;
setDisplayedText(null);
setDisplayedPermission(false);
return;
}
// Retry state has its own display — skip the normal queue
if (retryInfo) {
clearTimers();
queuedStatusRef.current = null;
return;
}
const incomingText = isWaitingForPermission ? 'waiting for permission' : statusText;
const incomingPermission = Boolean(isWaitingForPermission);
const incomingGeneric = Boolean(isGenericStatus) && !incomingPermission;
if (!incomingText) {
return;
}
if (!displayedTextRef.current) {
showStatus(incomingText, incomingPermission);
return;
}
if (incomingText === displayedTextRef.current && incomingPermission === displayedPermissionRef.current) {
return;
}
// Ignore generic churn.
if (incomingGeneric) {
return;
}
const elapsed = Date.now() - statusShownAtRef.current;
if (elapsed >= STATUS_DISPLAY_TIME_MS) {
showStatus(incomingText, incomingPermission);
return;
}
queuedStatusRef.current = { text: incomingText, permission: incomingPermission };
scheduleQueueProcess();
}, [
isWorking,
statusText,
isGenericStatus,
isWaitingForPermission,
retryInfo,
clearTimers,
showStatus,
scheduleQueueProcess,
]);
React.useEffect(() => () => clearTimers(), [clearTimers]);
if (!isWorking) {
return null;
}
// Retry state: show countdown and attempt info
if (retryInfo) {
const attemptLabel = retryInfo.attempt && retryInfo.attempt > 1 ? ` (attempt ${retryInfo.attempt})` : '';
const countdownLabel = retryCountdown !== null && retryCountdown > 0
? ` in ${formatRetryCountdown(retryCountdown)}`
: '';
const retryText = `Retrying${countdownLabel}${attemptLabel}`;
return (
<div
className="flex h-full items-center text-muted-foreground pl-0.5"
role="status"
aria-live="polite"
aria-label={`${retryText}...`}
>
<span className="typography-ui-header">
{retryText}
<BusyDots />
</span>
</div>
);
}
if (!displayedText) {
return null;
}
const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
return (
<div
className={
'flex h-full items-center text-muted-foreground pl-0.5'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="typography-ui-header">
{label}
<BusyDots />
</span>
</div>
);
}