Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
+339
View File
@@ -0,0 +1,339 @@
import React from 'react';
import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk';
import { useShallow } from 'zustand/react/shallow';
import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
import { useSessionStore } from '@/stores/useSessionStore';
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
import { useCurrentSessionActivity } from './useSessionActivity';
export type AssistantActivity = 'idle' | 'streaming' | 'tooling' | 'cooldown' | 'permission';
interface WorkingSummary {
activity: AssistantActivity;
hasWorkingContext: boolean;
hasActiveTools: boolean;
isWorking: boolean;
isStreaming: boolean;
isCooldown: boolean;
lifecyclePhase: MessageStreamPhase | null;
statusText: string | null;
isWaitingForPermission: boolean;
canAbort: boolean;
compactionDeadline: number | null;
activePartType?: 'text' | 'tool' | 'reasoning' | 'editing';
activeToolName?: string;
wasAborted: boolean;
abortActive: boolean;
lastCompletionId: string | null;
isComplete: boolean;
}
interface FormingSummary {
isActive: boolean;
characterCount: number;
}
export interface AssistantStatusSnapshot {
forming: FormingSummary;
working: WorkingSummary;
}
type AssistantMessageWithState = AssistantMessage & {
status?: string;
streaming?: boolean;
abortedAt?: number;
};
interface AssistantSessionMessageRecord {
info: AssistantMessageWithState;
parts: Part[];
}
const DEFAULT_WORKING: WorkingSummary = {
activity: 'idle',
hasWorkingContext: false,
hasActiveTools: false,
isWorking: false,
isStreaming: false,
isCooldown: false,
lifecyclePhase: null,
statusText: null,
isWaitingForPermission: false,
canAbort: false,
compactionDeadline: null,
activePartType: undefined,
activeToolName: undefined,
wasAborted: false,
abortActive: false,
lastCompletionId: null,
isComplete: false,
};
const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant';
const isReasoningPart = (part: Part): part is ReasoningPart => part.type === 'reasoning';
const isTextPart = (part: Part): part is TextPart => part.type === 'text';
const getLegacyTextContent = (part: Part): string | undefined => {
if (isTextPart(part)) {
return part.text;
}
const candidate = part as Partial<{ text?: unknown; content?: unknown; value?: unknown }>;
if (typeof candidate.text === 'string') {
return candidate.text;
}
if (typeof candidate.content === 'string') {
return candidate.content;
}
if (typeof candidate.value === 'string') {
return candidate.value;
}
return undefined;
};
const getPartTimeInfo = (part: Part): { end?: number } | undefined => {
if (isTextPart(part) || isReasoningPart(part)) {
return part.time;
}
const candidate = part as Partial<{ time?: { end?: number } }>;
return candidate.time;
};
const getToolDisplayName = (part: ToolPart): string => {
if (part.tool) {
return part.tool;
}
const candidate = part as ToolPart & Partial<{ name?: unknown }>;
return typeof candidate.name === 'string' ? candidate.name : 'tool';
};
export function useAssistantStatus(): AssistantStatusSnapshot {
const { currentSessionId, messages, permissions, sessionAbortFlags } = useSessionStore(
useShallow((state) => ({
currentSessionId: state.currentSessionId,
messages: state.messages,
permissions: state.permissions,
sessionAbortFlags: state.sessionAbortFlags,
}))
);
const { phase: activityPhase, isWorking: isPhaseWorking, isCooldown: isPhaseCooldown } = useCurrentSessionActivity();
const sessionMessages = React.useMemo<Array<{ info: Message; parts: Part[] }>>(() => {
if (!currentSessionId) {
return [];
}
const records = messages.get(currentSessionId) ?? [];
return records as Array<{ info: Message; parts: Part[] }>;
}, [currentSessionId, messages]);
type ParsedStatusResult = {
activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined;
activeToolName: string | undefined;
statusText: string;
};
const parsedStatus = React.useMemo<ParsedStatusResult>(() => {
if (sessionMessages.length === 0) {
return { activePartType: undefined, activeToolName: undefined, statusText: 'working' };
}
const assistantMessages = sessionMessages
.filter(
(msg): msg is AssistantSessionMessageRecord =>
isAssistantMessage(msg.info) && !isFullySyntheticMessage(msg.parts)
);
if (assistantMessages.length === 0) {
return { activePartType: undefined, activeToolName: undefined, statusText: 'working' };
}
const sortedAssistantMessages = [...assistantMessages].sort((a, b) => {
const aCreated = typeof a.info.time?.created === 'number' ? a.info.time.created : null;
const bCreated = typeof b.info.time?.created === 'number' ? b.info.time.created : null;
if (aCreated !== null && bCreated !== null && aCreated !== bCreated) {
return aCreated - bCreated;
}
return a.info.id.localeCompare(b.info.id);
});
const lastAssistant = sortedAssistantMessages[sortedAssistantMessages.length - 1];
let activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined = undefined;
let activeToolName: string | undefined = undefined;
const editingTools = new Set(['edit', 'write']);
for (let i = (lastAssistant.parts ?? []).length - 1; i >= 0; i -= 1) {
const part = lastAssistant.parts?.[i];
if (!part) continue;
switch (part.type) {
case 'reasoning': {
const time = part.time ?? getPartTimeInfo(part);
const stillRunning = !time || typeof time.end === 'undefined';
if (stillRunning && !activePartType) {
activePartType = 'reasoning';
}
break;
}
case 'tool': {
const toolStatus = part.state?.status;
if ((toolStatus === 'running' || toolStatus === 'pending') && !activePartType) {
const toolName = getToolDisplayName(part);
if (editingTools.has(toolName)) {
activePartType = 'editing';
} else {
activePartType = 'tool';
activeToolName = toolName;
}
}
break;
}
case 'text': {
const rawContent = getLegacyTextContent(part) ?? '';
if (typeof rawContent === 'string' && rawContent.trim().length > 0) {
const time = getPartTimeInfo(part);
const streamingPart = !time || typeof time.end === 'undefined';
if (streamingPart && !activePartType) {
activePartType = 'text';
}
}
break;
}
default:
break;
}
}
const statusText = (() => {
if (activePartType === 'editing') return 'editing';
if (activePartType === 'tool' && activeToolName) return `using ${activeToolName}`;
if (activePartType === 'reasoning') return 'thinking';
if (activePartType === 'text') return 'composing';
return 'working';
})();
return { activePartType, activeToolName, statusText };
}, [sessionMessages]);
const abortState = React.useMemo(() => {
const sessionId = currentSessionId;
const abortRecord = sessionId ? sessionAbortFlags?.get(sessionId) ?? null : null;
const hasActiveAbort = Boolean(abortRecord && !abortRecord.acknowledged);
return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort };
}, [currentSessionId, sessionAbortFlags]);
const baseWorking = React.useMemo<WorkingSummary>(() => {
if (abortState.wasAborted) {
return {
...DEFAULT_WORKING,
wasAborted: true,
abortActive: abortState.abortActive,
activity: 'idle',
hasWorkingContext: false,
isWorking: false,
isStreaming: false,
isCooldown: false,
statusText: null,
canAbort: false,
};
}
const isWorking = isPhaseWorking;
const isStreaming = activityPhase === 'busy';
const isCooldown = isPhaseCooldown;
let activity: AssistantActivity = 'idle';
if (isWorking) {
if (parsedStatus.activePartType === 'tool' || parsedStatus.activePartType === 'editing') {
activity = 'tooling';
} else {
activity = isCooldown ? 'cooldown' : 'streaming';
}
}
return {
activity,
hasWorkingContext: isWorking,
hasActiveTools: parsedStatus.activePartType === 'tool' || parsedStatus.activePartType === 'editing',
isWorking,
isStreaming,
isCooldown,
lifecyclePhase: isStreaming ? 'streaming' : isCooldown ? 'cooldown' : null,
statusText: isWorking ? parsedStatus.statusText : null,
isWaitingForPermission: false,
canAbort: isWorking,
compactionDeadline: null,
activePartType: isWorking ? parsedStatus.activePartType : undefined,
activeToolName: isWorking ? parsedStatus.activeToolName : undefined,
wasAborted: false,
abortActive: false,
lastCompletionId: null,
isComplete: isCooldown,
};
}, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]);
const forming = React.useMemo<FormingSummary>(() => {
const isActive = isPhaseWorking && parsedStatus.activePartType === 'text';
if (!isActive || sessionMessages.length === 0) {
return { isActive, characterCount: 0 };
}
const assistantMessages = sessionMessages.filter(
(msg): msg is AssistantSessionMessageRecord =>
isAssistantMessage(msg.info) && !isFullySyntheticMessage(msg.parts)
);
if (assistantMessages.length === 0) {
return { isActive, characterCount: 0 };
}
const lastAssistant = assistantMessages[assistantMessages.length - 1];
let characterCount = 0;
(lastAssistant.parts ?? []).forEach((part) => {
if (part.type !== 'text') return;
const rawContent = getLegacyTextContent(part) ?? '';
if (typeof rawContent === 'string' && rawContent.trim().length > 0) {
characterCount += rawContent.length;
}
});
return { isActive, characterCount };
}, [sessionMessages, isPhaseWorking, parsedStatus.activePartType]);
const working = React.useMemo<WorkingSummary>(() => {
if (baseWorking.wasAborted || baseWorking.abortActive) {
return baseWorking;
}
const sessionId = currentSessionId;
const permissionList = sessionId ? permissions?.get(sessionId) ?? [] : [];
const hasPendingPermission = permissionList.length > 0;
if (!hasPendingPermission) {
return baseWorking;
}
return {
...baseWorking,
statusText: 'waiting for permission',
isWaitingForPermission: true,
canAbort: false,
};
}, [currentSessionId, permissions, baseWorking]);
return {
forming,
working,
};
}
+229
View File
@@ -0,0 +1,229 @@
import React from 'react';
const DEFAULT_TIMEOUT_MS = 20000;
const LIFECYCLE_GRACE_MS = 8000;
type MessageStreamPhase = 'streaming' | 'cooldown' | 'completed';
interface MessageStreamLifecycle {
phase: MessageStreamPhase;
startedAt: number;
lastUpdateAt: number;
completedAt?: number;
}
interface MessagePart {
type?: string;
time?: { end?: number };
state?: { status?: string };
text?: string;
content?: string;
}
interface ChatMessageInfo {
id: string;
role: string;
time: { created: number; completed?: number; updated?: number };
animationSettled?: boolean;
}
interface ChatMessageRecord {
info: ChatMessageInfo;
parts: MessagePart[];
}
const hasFinalizedTextPart = (parts: MessagePart[]): boolean => {
return parts.some((part) => {
if (part?.type !== 'text') {
return false;
}
if (!part?.time || typeof part.time.end === 'undefined') {
return false;
}
const content = typeof part.text === 'string' ? part.text : part.content;
return Boolean(content && content.trim().length > 0);
});
};
const getAssistantMessagesAfterLastUser = (messages: ChatMessageRecord[]): ChatMessageRecord[] => {
let lastUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.info?.role === 'user') {
lastUserIndex = i;
break;
}
}
return messages.filter((message, index) => index > lastUserIndex && message?.info?.role === 'assistant');
};
const buildAssistantActivitySignature = (messages: ChatMessageRecord[]): string => {
return messages
.map((message) => {
const partSignature = (message.parts || [])
.map((part) => {
const type = part?.type || 'unknown';
const finalized = part?.time && typeof part.time.end !== 'undefined' ? '1' : '0';
const status = part?.state?.status || '';
const textLength = typeof part?.text === 'string' ? part.text.length : 0;
const contentLength = typeof part?.content === 'string' ? part.content.length : 0;
return `${type}:${finalized}:${status}:${textLength}:${contentLength}`;
})
.join('|');
const completed = message.info?.time?.completed || '';
const updated = message.info?.time?.updated || '';
return `${message.info?.id || 'unknown'}:${message.parts?.length || 0}:${completed}:${updated}:${partSignature}`;
})
.join('||');
};
interface UseAssistantTypingOptions {
messages: ChatMessageRecord[];
timeoutMs?: number;
messageStreamStates?: Map<string, MessageStreamLifecycle>;
}
interface UseAssistantTypingResult {
isTyping: boolean;
}
export const useAssistantTyping = ({
messages,
timeoutMs = DEFAULT_TIMEOUT_MS,
messageStreamStates,
}: UseAssistantTypingOptions): UseAssistantTypingResult => {
const assistantMessages = React.useMemo(() => getAssistantMessagesAfterLastUser(messages), [messages]);
const hasAssistantActivity = assistantMessages.length > 0;
const hasFinalAssistantText = assistantMessages.some((message) => hasFinalizedTextPart(message.parts));
const assistantHasUnsettledAnimation = assistantMessages.some((message) => {
return message.info.animationSettled !== true;
});
const hasActiveLifecycle = React.useMemo(() => {
if (!messageStreamStates || messageStreamStates.size === 0) {
return false;
}
return assistantMessages.some((message) => {
const lifecycle = messageStreamStates.get(message.info.id);
if (!lifecycle) {
return false;
}
return lifecycle.phase === 'streaming' || lifecycle.phase === 'cooldown';
});
}, [assistantMessages, messageStreamStates]);
const hasRunningTool = React.useMemo(() => {
return assistantMessages.some((message) =>
(message.parts || []).some(
(part) => part?.type === 'tool' && part?.state?.status === 'running'
)
);
}, [assistantMessages]);
const shouldShowBecauseOfLifecycle = hasAssistantActivity && (hasActiveLifecycle || hasRunningTool);
const shouldShowBasedOnContent = assistantHasUnsettledAnimation && hasAssistantActivity && !hasFinalAssistantText;
const [graceUntil, setGraceUntil] = React.useState<number | null>(null);
const previousLifecycleRef = React.useRef<boolean>(false);
React.useEffect(() => {
if (shouldShowBecauseOfLifecycle) {
setGraceUntil(null);
} else if (previousLifecycleRef.current) {
setGraceUntil(Date.now() + LIFECYCLE_GRACE_MS);
}
previousLifecycleRef.current = shouldShowBecauseOfLifecycle;
}, [shouldShowBecauseOfLifecycle]);
React.useEffect(() => {
if (graceUntil === null) {
return undefined;
}
const remaining = graceUntil - Date.now();
if (remaining <= 0) {
setGraceUntil(null);
return undefined;
}
const timer = window.setTimeout(() => {
setGraceUntil(null);
}, remaining);
return () => window.clearTimeout(timer);
}, [graceUntil]);
const withinLifecycleGrace = graceUntil !== null;
const shouldShowIndicator = shouldShowBasedOnContent || shouldShowBecauseOfLifecycle || withinLifecycleGrace;
const signatureRef = React.useRef<string | null>(null);
const [lastActivityAt, setLastActivityAt] = React.useState<number | null>(null);
const [hasTimedOut, setHasTimedOut] = React.useState(false);
React.useEffect(() => {
if (!shouldShowIndicator) {
signatureRef.current = null;
setLastActivityAt(null);
setHasTimedOut(false);
return;
}
const contentSignature = buildAssistantActivitySignature(assistantMessages);
const lifecycleSignature = messageStreamStates
? assistantMessages
.map((message) => {
const lifecycle = messageStreamStates.get(message.info.id);
if (!lifecycle) {
return `${message.info.id}:none`;
}
return `${message.info.id}:${lifecycle.phase}:${lifecycle.lastUpdateAt}:${
lifecycle.completedAt || ''
}`;
})
.join('||')
: '';
const signature = `${contentSignature}::${lifecycleSignature}::${hasRunningTool ? 'tool-running' : ''}::${
graceUntil ?? 'no-grace'
}`;
if (signatureRef.current !== signature) {
signatureRef.current = signature;
setLastActivityAt(Date.now());
setHasTimedOut(false);
}
}, [assistantMessages, shouldShowIndicator, messageStreamStates, hasRunningTool, graceUntil]);
React.useEffect(() => {
if (!shouldShowIndicator) {
return undefined;
}
if (lastActivityAt === null) {
return undefined;
}
const now = Date.now();
const elapsed = now - lastActivityAt;
if (elapsed >= timeoutMs) {
setHasTimedOut(true);
return undefined;
}
const remaining = timeoutMs - elapsed;
const timer = window.setTimeout(() => {
setHasTimedOut(true);
}, remaining);
return () => window.clearTimeout(timer);
}, [shouldShowIndicator, lastActivityAt, timeoutMs]);
const isTyping = shouldShowIndicator && !hasTimedOut;
return React.useMemo(() => ({ isTyping }), [isTyping]);
};
@@ -0,0 +1,49 @@
import React from 'react';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
export const useAvailableTools = () => {
const { tools: toolsAPI } = useRuntimeAPIs();
const [tools, setTools] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
const fetchTools = async () => {
setIsLoading(true);
setError(null);
try {
const availableTools = await toolsAPI.getAvailableTools();
if (cancelled) {
return;
}
setTools(availableTools);
} catch (err) {
if (cancelled) {
return;
}
const message = err instanceof Error ? err.message : 'Failed to fetch tools';
console.error('Failed to fetch available tools:', message);
setError(message);
setTools([]);
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
fetchTools();
return () => {
cancelled = true;
};
}, [toolsAPI]);
return { tools, isLoading, error };
};
@@ -0,0 +1,518 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { useScrollEngine } from './useScrollEngine';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
export type ContentChangeReason = 'text' | 'structural' | 'permission';
interface ChatMessageRecord {
info: Record<string, unknown>;
parts: Part[];
}
interface SessionMemoryState {
viewportAnchor: number;
isStreaming: boolean;
lastAccessedAt: number;
backgroundMessageCount: number;
totalAvailableMessages?: number;
hasMoreAbove?: boolean;
streamStartTime?: number;
isZombie?: boolean;
}
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
interface UseChatScrollManagerOptions {
currentSessionId: string | null;
sessionMessages: ChatMessageRecord[];
sessionPermissions: unknown[];
streamingMessageId: string | null;
sessionMemoryState: Map<string, SessionMemoryState>;
updateViewportAnchor: (sessionId: string, anchor: number) => void;
isSyncing: boolean;
isMobile: boolean;
messageStreamStates: Map<string, unknown>;
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
sessionActivityPhase?: Map<string, SessionActivityPhase>;
}
export interface AnimationHandlers {
onChunk: () => void;
onComplete: () => void;
onStreamingCandidate?: () => void;
onAnimationStart?: () => void;
onReservationCancelled?: () => void;
onReasoningBlock?: () => void;
onAnimatedHeightChange?: (height: number) => void;
}
interface UseChatScrollManagerResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
showScrollButton: boolean;
scrollToBottom: (options?: { instant?: boolean }) => void;
spacerHeight: number;
pendingAnchorId: string | null;
hasActiveAnchor: boolean;
}
const ANCHOR_TARGET_OFFSET = 50;
const DEFAULT_SCROLL_BUTTON_THRESHOLD = 40;
const LONG_MESSAGE_THRESHOLD = 0.20;
const LONG_MESSAGE_VISIBLE_PORTION = 0.10;
const VIEWPORT_RESIZE_DEBOUNCE_MS = 150;
const getMessageId = (message: ChatMessageRecord): string | null => {
const info = message.info;
if (typeof info?.id === 'string') {
return info.id;
}
return null;
};
const isUserMessage = (message: ChatMessageRecord): boolean => {
const info = message.info;
if (info?.userMessageMarker === true) {
return true;
}
const clientRole = info?.clientRole;
const serverRole = info?.role;
return clientRole === 'user' || serverRole === 'user';
};
export const useChatScrollManager = ({
currentSessionId,
sessionMessages,
updateViewportAnchor,
isSyncing,
isMobile,
sessionActivityPhase,
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
const [anchorId, setAnchorId] = React.useState<string | null>(null);
const [spacerHeight, setSpacerHeight] = React.useState(0);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [pendingAnchorId, setPendingAnchorId] = React.useState<string | null>(null);
const lastScrolledAnchorIdRef = React.useRef<string | null>(null);
const lastSessionIdRef = React.useRef<string | null>(null);
const lastMessageCountRef = React.useRef<number>(sessionMessages.length);
const spacerHeightRef = React.useRef(0);
const viewportHeightRef = React.useRef<number>(0);
const resizeTimeoutRef = React.useRef<number | undefined>(undefined);
const anchorIdRef = React.useRef<string | null>(null);
const hasAnchoredOnceRef = React.useRef<boolean>(false);
const currentPhase = currentSessionId
? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
: 'idle';
const updateSpacerHeight = React.useCallback((height: number) => {
const newHeight = Math.max(0, height);
if (spacerHeightRef.current !== newHeight) {
spacerHeightRef.current = newHeight;
setSpacerHeight(newHeight);
}
}, []);
const updateViewportCache = React.useCallback(() => {
const container = scrollRef.current;
if (container) {
viewportHeightRef.current = container.clientHeight;
}
}, []);
const getAnchorElement = React.useCallback((): HTMLElement | null => {
if (!anchorId) return null;
const container = scrollRef.current;
if (!container) return null;
return container.querySelector(`[data-message-id="${anchorId}"]`) as HTMLElement | null;
}, [anchorId]);
const isSpacerOutOfViewport = React.useCallback((): boolean => {
const container = scrollRef.current;
const currentSpacerHeight = spacerHeightRef.current;
if (!container || currentSpacerHeight <= 0) return true;
const spacerStartPosition = container.scrollHeight - currentSpacerHeight;
const viewportBottom = container.scrollTop + container.clientHeight;
return viewportBottom < spacerStartPosition;
}, []);
const calculateAnchorPosition = React.useCallback((
anchorElement: HTMLElement,
containerHeight: number
): number => {
const messageHeight = anchorElement.offsetHeight;
const messageTop = anchorElement.offsetTop;
const isLongMessage = messageHeight > containerHeight * LONG_MESSAGE_THRESHOLD;
if (isLongMessage) {
const visiblePortion = containerHeight * LONG_MESSAGE_VISIBLE_PORTION;
const messageBottom = messageTop + messageHeight;
return messageBottom - visiblePortion;
} else {
return messageTop - ANCHOR_TARGET_OFFSET;
}
}, []);
const refreshSpacer = React.useCallback(() => {
const container = scrollRef.current;
if (!container || !anchorIdRef.current) {
return;
}
const anchorElement = getAnchorElement();
if (!anchorElement) {
return;
}
const containerHeight = container.clientHeight;
const contentHeight = container.scrollHeight;
const targetScrollTop = calculateAnchorPosition(anchorElement, containerHeight);
const requiredHeight = targetScrollTop + containerHeight;
const currentSpacerHeight = spacerHeightRef.current;
const contentWithoutSpacer = contentHeight - currentSpacerHeight;
if (!hasAnchoredOnceRef.current && contentWithoutSpacer < requiredHeight) {
const needed = requiredHeight - contentWithoutSpacer;
if (needed > currentSpacerHeight) {
updateSpacerHeight(needed);
}
}
}, [calculateAnchorPosition, getAnchorElement, updateSpacerHeight]);
const updateScrollButtonVisibility = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
setShowScrollButton(false);
return;
}
if (pendingAnchorId) {
setShowScrollButton(false);
return;
}
const hasScrollableContent = container.scrollHeight > container.clientHeight;
if (!hasScrollableContent) {
setShowScrollButton(false);
return;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const currentSpacerHeight = spacerHeightRef.current;
if (currentSpacerHeight > 0) {
const spacerStartPosition = container.scrollHeight - currentSpacerHeight;
const viewportBottom = container.scrollTop + container.clientHeight;
setShowScrollButton(viewportBottom < spacerStartPosition);
} else {
setShowScrollButton(distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD);
}
}, [pendingAnchorId]);
const scrollToBottom = React.useCallback((options?: { instant?: boolean }) => {
const container = scrollRef.current;
if (!container) return;
const bottom = container.scrollHeight - container.clientHeight;
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
}, [scrollEngine]);
const scrollToNewAnchor = React.useCallback((messageId: string) => {
if (lastScrolledAnchorIdRef.current === messageId) {
return;
}
lastScrolledAnchorIdRef.current = messageId;
setPendingAnchorId(messageId);
const container = scrollRef.current;
if (!container) {
setPendingAnchorId(null);
return;
}
const contentHeight = container.scrollHeight;
const currentSpacer = spacerHeightRef.current;
const contentWithoutSpacer = contentHeight - currentSpacer;
const containerHeight = viewportHeightRef.current > 0
? viewportHeightRef.current
: container.clientHeight;
const estimatedMessageTop = contentWithoutSpacer;
const targetScrollTop = estimatedMessageTop - ANCHOR_TARGET_OFFSET;
const requiredHeight = targetScrollTop + containerHeight;
let newSpacerHeight = 0;
if (contentWithoutSpacer < requiredHeight) {
newSpacerHeight = requiredHeight - contentWithoutSpacer;
}
if (newSpacerHeight !== currentSpacer) {
updateSpacerHeight(newSpacerHeight);
}
hasAnchoredOnceRef.current = true;
window.requestAnimationFrame(() => {
scrollEngine.scrollToPosition(targetScrollTop, { instant: true });
window.requestAnimationFrame(() => {
setPendingAnchorId(null);
});
});
}, [scrollEngine, updateSpacerHeight]);
const handleScrollEvent = React.useCallback(() => {
const container = scrollRef.current;
if (!container || !currentSessionId) {
return;
}
scrollEngine.handleScroll();
updateScrollButtonVisibility();
if (currentPhase === 'idle' && spacerHeightRef.current > 0 && isSpacerOutOfViewport()) {
updateSpacerHeight(0);
anchorIdRef.current = null;
setAnchorId(null);
}
const { scrollTop, scrollHeight, clientHeight } = container;
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
const estimatedIndex = Math.floor(position * sessionMessages.length);
updateViewportAnchor(currentSessionId, estimatedIndex);
}, [
currentSessionId,
currentPhase,
isSpacerOutOfViewport,
scrollEngine,
sessionMessages.length,
updateScrollButtonVisibility,
updateSpacerHeight,
updateViewportAnchor,
]);
React.useEffect(() => {
const container = scrollRef.current;
if (!container) return;
container.addEventListener('scroll', handleScrollEvent, { passive: true });
return () => {
container.removeEventListener('scroll', handleScrollEvent);
};
}, [handleScrollEvent]);
useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') return;
updateViewportCache();
const handleResize = () => {
if (resizeTimeoutRef.current !== undefined) {
window.clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = window.setTimeout(() => {
updateViewportCache();
}, VIEWPORT_RESIZE_DEBOUNCE_MS);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (resizeTimeoutRef.current !== undefined) {
window.clearTimeout(resizeTimeoutRef.current);
}
};
}, [updateViewportCache]);
React.useEffect(() => {
if (currentSessionId && currentSessionId !== lastSessionIdRef.current) {
lastSessionIdRef.current = currentSessionId;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
lastMessageCountRef.current = sessionMessages.length;
lastScrolledAnchorIdRef.current = null;
anchorIdRef.current = null;
hasAnchoredOnceRef.current = false;
setAnchorId(null);
spacerHeightRef.current = 0;
setSpacerHeight(0);
}
}, [currentSessionId, sessionMessages.length]);
useIsomorphicLayoutEffect(() => {
if (isSyncing) {
lastMessageCountRef.current = sessionMessages.length;
return;
}
if (lastSessionIdRef.current !== currentSessionId) {
return;
}
const previousCount = lastMessageCountRef.current;
const nextCount = sessionMessages.length;
if (nextCount > previousCount && previousCount > 0) {
const previousLastId = getMessageId(sessionMessages[previousCount - 1]);
const wasAppended = previousLastId !== null &&
getMessageId(sessionMessages[Math.min(previousCount - 1, nextCount - 1)]) === previousLastId;
if (wasAppended) {
const appendedMessages = sessionMessages.slice(previousCount, nextCount);
const newUserMessage = appendedMessages.find(isUserMessage);
if (newUserMessage) {
const newAnchorId = getMessageId(newUserMessage);
if (newAnchorId) {
anchorIdRef.current = newAnchorId;
setAnchorId(newAnchorId);
scrollToNewAnchor(newAnchorId);
}
} else {
refreshSpacer();
}
} else {
refreshSpacer();
}
}
lastMessageCountRef.current = nextCount;
}, [currentSessionId, isSyncing, refreshSpacer, scrollToNewAnchor, sessionMessages]);
React.useEffect(() => {
const container = scrollRef.current;
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
refreshSpacer();
updateScrollButtonVisibility();
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, [refreshSpacer, updateScrollButtonVisibility]);
React.useEffect(() => {
if (anchorId) {
refreshSpacer();
updateScrollButtonVisibility();
}
}, [anchorId, refreshSpacer, updateScrollButtonVisibility]);
React.useEffect(() => {
if (currentPhase === 'idle' && spacerHeightRef.current > 0 && isSpacerOutOfViewport()) {
updateSpacerHeight(0);
anchorIdRef.current = null;
hasAnchoredOnceRef.current = false;
setAnchorId(null);
}
}, [currentPhase, isSpacerOutOfViewport, updateSpacerHeight]);
React.useEffect(() => {
updateScrollButtonVisibility();
}, [spacerHeight, updateScrollButtonVisibility]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const handleMessageContentChange = React.useCallback(() => {
refreshSpacer();
updateScrollButtonVisibility();
}, [refreshSpacer, updateScrollButtonVisibility]);
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const existing = animationHandlersRef.current.get(messageId);
if (existing) {
return existing;
}
const handlers: AnimationHandlers = {
onChunk: () => {
refreshSpacer();
},
onComplete: () => {
refreshSpacer();
},
onStreamingCandidate: () => {
},
onAnimationStart: () => {
},
onAnimatedHeightChange: () => {
refreshSpacer();
},
onReservationCancelled: () => {
},
onReasoningBlock: () => {
},
};
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [refreshSpacer]);
return {
scrollRef,
handleMessageContentChange,
getAnimationHandlers,
showScrollButton,
scrollToBottom,
spacerHeight,
pendingAnchorId,
hasActiveAnchor: anchorId !== null,
};
};
@@ -0,0 +1,17 @@
import * as React from 'react';
export const useDebouncedValue = <T>(value: T, delayMs = 200): T => {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const handle = window.setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
window.clearTimeout(handle);
};
}, [value, delayMs]);
return debouncedValue;
};
@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import {
fetchDesktopServerInfo,
isDesktopRuntime,
type DesktopServerInfo
} from "@/lib/desktop";
export const useDesktopServerInfo = (pollInterval = 5000): DesktopServerInfo | null => {
const [info, setInfo] = useState<DesktopServerInfo | null>(null);
useEffect(() => {
if (!isDesktopRuntime()) {
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
const payload = await fetchDesktopServerInfo();
if (!cancelled) {
setInfo(payload);
timer = setTimeout(poll, pollInterval);
}
};
poll();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [pollInterval]);
return info;
};
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useRef } from 'react';
import { useUIStore } from '@/stores/useUIStore';
interface EdgeSwipeOptions {
edgeThreshold?: number;
minSwipeDistance?: number;
maxSwipeTime?: number;
enabled?: boolean;
}
export const useEdgeSwipe = (options: EdgeSwipeOptions = {}) => {
const {
edgeThreshold = 30,
minSwipeDistance = 50,
maxSwipeTime = 300,
enabled = true,
} = options;
const {
isMobile,
setSessionSwitcherOpen,
isSessionSwitcherOpen,
} = useUIStore();
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
const touchEndRef = useRef<{ x: number; y: number; time: number } | null>(null);
useEffect(() => {
if (!enabled || !isMobile) return;
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
if (!touch) {
touchStartRef.current = null;
return;
}
const fromLeft = touch.clientX <= edgeThreshold;
if (fromLeft) {
touchStartRef.current = {
x: touch.clientX,
y: touch.clientY,
time: Date.now(),
};
} else {
touchStartRef.current = null;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!touchStartRef.current) {
return;
}
const touch = e.touches[0];
if (!touch) {
return;
}
const deltaX = touch.clientX - touchStartRef.current.x;
if (deltaX > 10) {
e.preventDefault();
}
};
const handleTouchEnd = (e: TouchEvent) => {
if (!touchStartRef.current) return;
const touch = e.changedTouches[0];
if (!touch) {
touchStartRef.current = null;
touchEndRef.current = null;
return;
}
touchEndRef.current = {
x: touch.clientX,
y: touch.clientY,
time: Date.now(),
};
const { x: startX, y: startY, time: startTime } = touchStartRef.current;
const { x: endX, y: endY, time: endTime } = touchEndRef.current;
const deltaX = endX - startX;
const deltaY = endY - startY;
const deltaTime = endTime - startTime;
const isHorizontal = Math.abs(deltaY) < Math.abs(deltaX);
const isQuick = deltaTime <= maxSwipeTime;
const limitedVertical = Math.abs(deltaY) < minSwipeDistance;
const isValidLeftSwipe =
deltaX >= minSwipeDistance && isHorizontal && isQuick && limitedVertical;
if (isValidLeftSwipe && !isSessionSwitcherOpen) {
setSessionSwitcherOpen(true);
}
touchStartRef.current = null;
touchEndRef.current = null;
};
const handleTouchCancel = () => {
touchStartRef.current = null;
touchEndRef.current = null;
};
document.addEventListener('touchstart', handleTouchStart, { passive: true, capture: true });
document.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
document.addEventListener('touchend', handleTouchEnd, { passive: true, capture: true });
document.addEventListener('touchcancel', handleTouchCancel, { passive: true, capture: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart, { capture: true });
document.removeEventListener('touchmove', handleTouchMove, { capture: true });
document.removeEventListener('touchend', handleTouchEnd, { capture: true });
document.removeEventListener('touchcancel', handleTouchCancel, { capture: true });
};
}, [
enabled,
isMobile,
edgeThreshold,
minSwipeDistance,
maxSwipeTime,
setSessionSwitcherOpen,
isSessionSwitcherOpen,
]);
return null;
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from 'react';
import { isDesktopRuntime, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
export const useFileSystemAccess = () => {
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
setIsDesktop(isDesktopRuntime());
}, []);
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; error?: string }> => {
if (!isDesktop) {
return { success: true, path: directoryPath };
}
return await requestDirectoryAccess(directoryPath);
}, [isDesktop]);
const startAccessing = useCallback(async (directoryPath: string): Promise<{ success: boolean; error?: string }> => {
if (!isDesktop) {
return { success: true };
}
return await startAccessingDirectory(directoryPath);
}, [isDesktop]);
const stopAccessing = useCallback(async (directoryPath: string): Promise<{ success: boolean; error?: string }> => {
if (!isDesktop) {
return { success: true };
}
return await stopAccessingDirectory(directoryPath);
}, [isDesktop]);
return {
isDesktop,
requestAccess,
startAccessing,
stopAccessing
};
};
+53
View File
@@ -0,0 +1,53 @@
import * as React from 'react';
interface FireworksOptions {
durationMs?: number;
}
interface FireworksState {
isActive: boolean;
cycle: number;
}
export interface UseFireworksResult {
isActive: boolean;
burstKey: number;
triggerFireworks: () => void;
dismissFireworks: () => void;
}
export const useFireworks = ({ durationMs = 3400 }: FireworksOptions = {}): UseFireworksResult => {
const [state, setState] = React.useState<FireworksState>({ isActive: false, cycle: 0 });
const timeoutRef = React.useRef<number | null>(null);
const clearTimer = React.useCallback(() => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const dismissFireworks = React.useCallback(() => {
clearTimer();
setState((prev) => (prev.isActive ? { ...prev, isActive: false } : prev));
}, [clearTimer]);
const triggerFireworks = React.useCallback(() => {
clearTimer();
setState((prev) => ({ isActive: true, cycle: prev.cycle + 1 }));
timeoutRef.current = window.setTimeout(() => {
setState((prev) => (prev.isActive ? { ...prev, isActive: false } : prev));
timeoutRef.current = null;
}, durationMs);
}, [clearTimer, durationMs]);
React.useEffect(() => () => clearTimer(), [clearTimer]);
return {
isActive: state.isActive,
burstKey: state.cycle,
triggerFireworks,
dismissFireworks,
};
};
@@ -0,0 +1,13 @@
import type { MonoFontOption, UiFontOption } from '@/lib/fontOptions';
interface FontPreferences {
uiFont: UiFontOption;
monoFont: MonoFontOption;
}
export const useFontPreferences = (): FontPreferences => {
return {
uiFont: 'ibm-plex-sans',
monoFont: 'ibm-plex-mono',
};
};
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import { useGitPolling } from '@/hooks/useGitPollingHook';
/**
* Component wrapper for useGitPolling - use this inside RuntimeAPIProvider
*/
export function GitPollingProvider({ children }: { children: React.ReactNode }) {
useGitPolling();
return <>{children}</>;
}
@@ -0,0 +1,31 @@
import React from 'react';
import { useGitStore } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
/**
* Background git polling hook - monitors git status regardless of which tab is open.
* Must be used inside RuntimeAPIProvider.
*/
export function useGitPolling() {
const { git } = useRuntimeAPIs();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const { setActiveDirectory, startPolling, stopPolling, fetchAll } = useGitStore();
React.useEffect(() => {
if (!currentDirectory || !git) {
stopPolling();
return;
}
setActiveDirectory(currentDirectory);
fetchAll(currentDirectory, git);
startPolling(git);
return () => {
stopPolling();
};
}, [currentDirectory, git, setActiveDirectory, startPolling, stopPolling, fetchAll]);
}
@@ -0,0 +1,208 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
export const useKeyboardShortcuts = () => {
const { createSession, abortCurrentOperation, initializeNewOpenChamberSession, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
const {
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
} = useUIStore();
const { agents } = useConfigStore();
const { themeMode, setThemeMode } = useThemeSystem();
const { working } = useAssistantStatus();
const abortPrimedUntilRef = React.useRef<number | null>(null);
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const isDownloadingLogsRef = React.useRef(false);
const resetAbortPriming = React.useCallback(() => {
if (abortPrimedTimeoutRef.current) {
clearTimeout(abortPrimedTimeoutRef.current);
abortPrimedTimeoutRef.current = null;
}
abortPrimedUntilRef.current = null;
clearAbortPrompt();
}, [clearAbortPrompt]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'x') {
e.preventDefault();
toggleCommandPalette();
}
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'l') {
const runtimeAPIs = getRegisteredRuntimeAPIs();
const diagnostics = runtimeAPIs?.diagnostics;
if (!diagnostics) {
return;
}
e.preventDefault();
if (isDownloadingLogsRef.current) {
return;
}
isDownloadingLogsRef.current = true;
diagnostics
.downloadLogs()
.then(({ fileName, content }) => {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName || 'desktop.log';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
})
.finally(() => {
isDownloadingLogsRef.current = false;
});
return;
}
if (e.ctrlKey && e.key === 'h') {
e.preventDefault();
toggleHelpDialog();
}
if (e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'n') {
e.preventDefault();
if (e.shiftKey) {
setSessionCreateDialogOpen(true);
return;
}
createSession().then(session => {
if (session) {
initializeNewOpenChamberSession(session.id, agents);
}
});
}
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
e.preventDefault();
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const currentIndex = modes.indexOf(themeMode);
const nextIndex = (currentIndex + 1) % modes.length;
setThemeMode(modes[nextIndex]);
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'g') {
e.preventDefault();
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'git' ? 'chat' : 'git');
return;
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault();
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
return;
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === ',') {
e.preventDefault();
const { isSettingsDialogOpen } = useUIStore.getState();
setSettingsDialogOpen(!isSettingsDialogOpen);
return;
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
if (isMobile) {
setSessionSwitcherOpen(!isSessionSwitcherOpen);
} else {
toggleSidebar();
}
return;
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'i') {
e.preventDefault();
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
return;
}
if (e.key === 'Escape') {
const sessionId = currentSessionId;
const canAbortNow = working.canAbort && Boolean(sessionId);
if (!canAbortNow) {
resetAbortPriming();
return;
}
const now = Date.now();
const primedUntil = abortPrimedUntilRef.current;
if (primedUntil && now < primedUntil) {
e.preventDefault();
resetAbortPriming();
void abortCurrentOperation();
return;
}
e.preventDefault();
const expiresAt = armAbortPrompt(3000) ?? now + 3000;
abortPrimedUntilRef.current = expiresAt;
if (abortPrimedTimeoutRef.current) {
clearTimeout(abortPrimedTimeoutRef.current);
}
const delay = Math.max(expiresAt - now, 0);
abortPrimedTimeoutRef.current = setTimeout(() => {
if (abortPrimedUntilRef.current && Date.now() >= abortPrimedUntilRef.current) {
resetAbortPriming();
}
}, delay || 0);
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [
createSession,
abortCurrentOperation,
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setThemeMode,
themeMode,
initializeNewOpenChamberSession,
agents,
working,
armAbortPrompt,
resetAbortPriming,
currentSessionId,
]);
React.useEffect(() => {
return () => {
resetAbortPriming();
};
}, [resetAbortPriming]);
};
+252
View File
@@ -0,0 +1,252 @@
import React from 'react';
import type { AssistantMessage, Message, Part } from '@opencode-ai/sdk';
import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore';
import { opencodeClient } from '@/lib/opencode/client';
import { readSessionCursor } from '@/lib/messageCursorPersistence';
import { extractTextFromPart } from '@/stores/utils/messageUtils';
type SessionMessageRecord = { info: Message; parts: Part[] };
const isAssistantMessage = (message: Message): message is AssistantMessage => message.role === 'assistant';
const getCompletionTimestamp = (record: { info: Message }): number | undefined => {
const message = record.info;
if (!isAssistantMessage(message)) {
return undefined;
}
const completed = message.time?.completed;
return typeof completed === 'number' ? completed : undefined;
};
const isUserMessageInfo = (info?: Message): boolean => {
if (!info) return false;
if (info.role === 'user') return true;
const infoExt = info as { clientRole?: string; userMessageMarker?: boolean };
if (infoExt.clientRole === 'user') return true;
return Boolean(infoExt.userMessageMarker);
};
const normalizeMessageText = (message?: SessionMessageRecord): string => {
const parts = Array.isArray(message?.parts) ? message.parts : [];
const raw = parts
.map((part) => extractTextFromPart(part))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
return raw;
};
const findServerIndexForLocalUserMessage = (
localMessage: SessionMessageRecord | undefined,
serverMessages: SessionMessageRecord[]
): number => {
if (!localMessage || !isUserMessageInfo(localMessage.info)) {
return -1;
}
const localText = normalizeMessageText(localMessage);
const localCreated =
typeof localMessage.info?.time?.created === 'number' ? localMessage.info.time.created : undefined;
let bestIndex = -1;
let bestScore = Number.POSITIVE_INFINITY;
serverMessages.forEach((candidate, index) => {
if (!isUserMessageInfo(candidate.info)) {
return;
}
const candidateText = normalizeMessageText(candidate);
const textMatches = Boolean(localText && candidateText && candidateText === localText);
const candidateCreated =
typeof candidate.info?.time?.created === 'number' ? candidate.info.time.created : undefined;
const timeDelta =
typeof localCreated === 'number' && typeof candidateCreated === 'number'
? Math.abs(candidateCreated - localCreated)
: null;
if (textMatches) {
const score = typeof timeDelta === 'number' ? timeDelta : 0;
if (score < bestScore) {
bestIndex = index;
bestScore = score;
}
return;
}
if (!localText && !candidateText && typeof timeDelta === 'number' && timeDelta < 1500 && timeDelta < bestScore) {
bestIndex = index;
bestScore = timeDelta;
}
});
if (bestIndex !== -1) {
return bestIndex;
}
if (typeof localCreated === 'number') {
return serverMessages.findIndex((candidate) => {
if (!isUserMessageInfo(candidate.info)) return false;
const candidateCreated =
typeof candidate.info?.time?.created === 'number' ? candidate.info.time.created : undefined;
if (typeof candidateCreated !== 'number') return false;
return Math.abs(candidateCreated - localCreated) < 1000;
});
}
return -1;
};
export const useMessageSync = () => {
const {
currentSessionId,
messages,
streamingMessageIds
} = useSessionStore();
const streamingMessageId = React.useMemo(() => {
if (!currentSessionId) return null;
return streamingMessageIds.get(currentSessionId) ?? null;
}, [currentSessionId, streamingMessageIds]);
const syncTimeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
const lastSyncRef = React.useRef<number>(0);
const syncMessages = React.useCallback(async () => {
if (!currentSessionId) return;
if (streamingMessageId) return;
const now = Date.now();
if (now - lastSyncRef.current < 2000) return;
lastSyncRef.current = now;
try {
const currentMessages = (messages.get(currentSessionId) || []) as SessionMessageRecord[];
const latestMessages = (await opencodeClient.getSessionMessages(currentSessionId)) as SessionMessageRecord[];
const cursorRecord = await readSessionCursor(currentSessionId);
if (!latestMessages) return;
const lastLocalMessage = currentMessages[currentMessages.length - 1];
if (lastLocalMessage) {
const directIndex = latestMessages.findIndex((m) => m.info.id === lastLocalMessage.info.id);
const fuzzyIndex =
directIndex === -1
? findServerIndexForLocalUserMessage(lastLocalMessage, latestMessages)
: directIndex;
const lastLocalIndex = fuzzyIndex;
if (lastLocalIndex !== -1) {
if (lastLocalIndex < latestMessages.length - 1) {
const newMessages = latestMessages.slice(lastLocalIndex + 1);
console.log(`[SYNC] Found ${newMessages.length} new messages to append`);
const updatedMessages = [...currentMessages, ...newMessages];
const { syncMessages } = useSessionStore.getState();
syncMessages(currentSessionId, updatedMessages);
} else {
const serverLastMessage = latestMessages[lastLocalIndex];
const localLastMessage = currentMessages[currentMessages.length - 1];
const serverCompleted = getCompletionTimestamp(serverLastMessage);
const localCompleted = getCompletionTimestamp(localLastMessage);
if (serverCompleted && !localCompleted) {
console.log('[SYNC] Last message completed on server');
const updatedMessages = [...currentMessages.slice(0, -1), serverLastMessage];
const { syncMessages } = useSessionStore.getState();
syncMessages(currentSessionId, updatedMessages);
}
}
} else {
if (isUserMessageInfo(lastLocalMessage.info)) {
const messagesToLoad = latestMessages.slice(-MEMORY_LIMITS.VIEWPORT_MESSAGES);
console.log('[SYNC] Local user message missing by ID; merging latest messages for deduplication');
const { syncMessages } = useSessionStore.getState();
syncMessages(currentSessionId, messagesToLoad);
} else {
console.log('[SYNC] Local messages not found on server - skipping sync');
}
}
} else if (cursorRecord) {
const cursorIndex = latestMessages.findIndex(m => m.info.id === cursorRecord.messageId);
if (cursorIndex !== -1) {
if (cursorIndex < latestMessages.length - 1) {
const newMessages = latestMessages.slice(cursorIndex + 1);
const limited = newMessages.slice(-MEMORY_LIMITS.VIEWPORT_MESSAGES);
if (limited.length > 0) {
console.log(`[SYNC] Restoring ${limited.length} messages after cursor`);
const { syncMessages } = useSessionStore.getState();
syncMessages(currentSessionId, limited);
}
}
} else if (latestMessages.length > 0) {
console.log('[SYNC] Cursor not found on server response, loading recent messages');
const messagesToLoad = latestMessages.slice(-MEMORY_LIMITS.VIEWPORT_MESSAGES);
const { syncMessages } = useSessionStore.getState();
syncMessages(currentSessionId, messagesToLoad);
}
} else if (latestMessages.length > 0) {
const messagesToLoad = latestMessages.slice(-MEMORY_LIMITS.VIEWPORT_MESSAGES);
console.log(`[SYNC] Loading last ${messagesToLoad.length} messages`);
const { syncMessages } = useSessionStore.getState();
syncMessages(currentSessionId, messagesToLoad);
}
} catch (error) {
console.debug('Background sync failed:', error);
}
}, [currentSessionId, messages, streamingMessageId]);
React.useEffect(() => {
const handleFocus = () => {
console.log('[FOCUS] Window focused - checking for updates');
syncMessages();
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [syncMessages]);
React.useEffect(() => {
if (!currentSessionId || streamingMessageId) return;
const scheduleSync = () => {
if (document.visibilityState === 'visible') {
syncMessages();
}
syncTimeoutRef.current = setTimeout(scheduleSync, 30000);
};
syncTimeoutRef.current = setTimeout(scheduleSync, 30000);
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, [currentSessionId, streamingMessageId, syncMessages]);
React.useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
console.log('[FOCUS] Tab became visible - checking for updates');
syncMessages();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [syncMessages]);
};
+61
View File
@@ -0,0 +1,61 @@
import { useState, useCallback, useEffect } from 'react';
type LogoSource = 'local' | 'remote' | 'none';
interface UseProviderLogoReturn {
src: string | null;
onError: () => void;
hasLogo: boolean;
}
const localLogoModules = import.meta.glob<string>('../assets/provider-logos/*.svg', {
eager: true,
import: 'default',
});
const LOCAL_PROVIDER_LOGO_MAP = new Map<string, string>();
for (const [path, url] of Object.entries(localLogoModules)) {
const match = path.match(/provider-logos\/([^/]+)\.svg$/i);
if (match?.[1] && url) {
LOCAL_PROVIDER_LOGO_MAP.set(match[1].toLowerCase(), url);
}
}
export function useProviderLogo(providerId: string | null | undefined): UseProviderLogoReturn {
const normalizedId = providerId?.toLowerCase() ?? null;
const hasLocalLogo = normalizedId ? LOCAL_PROVIDER_LOGO_MAP.has(normalizedId) : false;
const localLogoSrc = normalizedId ? LOCAL_PROVIDER_LOGO_MAP.get(normalizedId) ?? null : null;
const [source, setSource] = useState<LogoSource>(hasLocalLogo ? 'local' : 'remote');
useEffect(() => {
setSource(hasLocalLogo ? 'local' : 'remote');
}, [hasLocalLogo, normalizedId]);
const handleError = useCallback(() => {
setSource((current) => (current === 'local' && hasLocalLogo ? 'remote' : 'none'));
}, [hasLocalLogo]);
if (!normalizedId) {
return { src: null, onError: handleError, hasLogo: false };
}
if (source === 'local' && localLogoSrc) {
return {
src: localLogoSrc,
onError: handleError,
hasLogo: true,
};
}
if (source === 'remote') {
return {
src: `https://models.dev/logos/${normalizedId}.svg`,
onError: handleError,
hasLogo: true,
};
}
return { src: null, onError: handleError, hasLogo: false };
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import type { RuntimeAPISelector, RuntimeAPIs } from '@/lib/api/types';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
export const useRuntimeAPIs = (): RuntimeAPIs => {
const apis = React.useContext(RuntimeAPIContext);
if (!apis) {
throw new Error('Runtime APIs are not available. Did you forget to wrap the app in <RuntimeAPIProvider>?');
}
return apis;
};
export const useRuntimeAPI = <TValue,>(selector: RuntimeAPISelector<TValue>): TValue => {
const apis = useRuntimeAPIs();
return selector(apis);
};
export const useIsDesktopRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isDesktop);
+211
View File
@@ -0,0 +1,211 @@
import React from 'react';
type ScrollEngineOptions = {
containerRef: React.RefObject<HTMLDivElement | null>;
isMobile: boolean;
};
type ScrollOptions = {
instant?: boolean;
};
type ScrollEngineResult = {
handleScroll: () => void;
scrollToPosition: (position: number, options?: ScrollOptions) => void;
forceManualMode: () => void;
isAtTop: boolean;
isManualOverrideActive: () => boolean;
getScrollTop: () => number;
getScrollHeight: () => number;
getClientHeight: () => number;
};
const ANIMATION_DURATION_MS = 160;
export const useScrollEngine = ({
containerRef,
}: ScrollEngineOptions): ScrollEngineResult => {
const [isAtTop, setIsAtTop] = React.useState(true);
const atTopRef = React.useRef(true);
const manualOverrideRef = React.useRef(false);
const animationFrameRef = React.useRef<number | null>(null);
const animationStartRef = React.useRef<number | null>(null);
const animationFromRef = React.useRef(0);
const animationTargetRef = React.useRef(0);
const cancelAnimation = React.useCallback(() => {
if (animationFrameRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = null;
animationStartRef.current = null;
}, []);
const runAnimationFrame = React.useCallback(
(timestamp: number) => {
const container = containerRef.current;
if (!container) {
cancelAnimation();
return;
}
if (animationStartRef.current === null) {
animationStartRef.current = timestamp;
}
const progress = Math.min(1, (timestamp - animationStartRef.current) / ANIMATION_DURATION_MS);
const easedProgress = 1 - Math.pow(1 - progress, 3);
const from = animationFromRef.current;
const target = animationTargetRef.current;
const nextTop = from + (target - from) * easedProgress;
container.scrollTop = nextTop;
if (progress < 1) {
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
return;
}
container.scrollTop = target;
cancelAnimation();
if (atTopRef.current) {
atTopRef.current = false;
setIsAtTop(false);
}
},
[cancelAnimation, containerRef, setIsAtTop]
);
const scrollToPosition = React.useCallback(
(position: number, options?: ScrollOptions) => {
const container = containerRef.current;
if (!container) return;
const target = Math.max(0, position);
const preferInstant = options?.instant ?? false;
manualOverrideRef.current = false;
if (typeof window === 'undefined' || preferInstant) {
cancelAnimation();
container.scrollTop = target;
const atTop = target <= 1;
if (atTopRef.current !== atTop) {
atTopRef.current = atTop;
setIsAtTop(atTop);
}
return;
}
cancelAnimation();
const distance = Math.abs(target - container.scrollTop);
if (distance <= 0.5) {
container.scrollTop = target;
const atTop = target <= 1;
if (atTopRef.current !== atTop) {
atTopRef.current = atTop;
setIsAtTop(atTop);
}
return;
}
animationFromRef.current = container.scrollTop;
animationTargetRef.current = target;
animationStartRef.current = null;
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
},
[cancelAnimation, containerRef, runAnimationFrame, setIsAtTop]
);
const forceManualMode = React.useCallback(() => {
manualOverrideRef.current = true;
}, []);
const markManualOverride = React.useCallback(() => {
manualOverrideRef.current = true;
}, []);
const isManualOverrideActive = React.useCallback(() => {
return manualOverrideRef.current;
}, []);
const getScrollTop = React.useCallback(() => {
return containerRef.current?.scrollTop ?? 0;
}, [containerRef]);
const getScrollHeight = React.useCallback(() => {
return containerRef.current?.scrollHeight ?? 0;
}, [containerRef]);
const getClientHeight = React.useCallback(() => {
return containerRef.current?.clientHeight ?? 0;
}, [containerRef]);
const handleScroll = React.useCallback(() => {
const container = containerRef.current;
if (!container) return;
if (manualOverrideRef.current && animationFrameRef.current !== null) {
cancelAnimation();
}
const atTop = container.scrollTop <= 1;
if (atTopRef.current !== atTop) {
atTopRef.current = atTop;
setIsAtTop(atTop);
}
}, [cancelAnimation, containerRef]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('wheel', markManualOverride, { passive: true });
container.addEventListener('touchstart', markManualOverride, { passive: true });
return () => {
container.removeEventListener('wheel', markManualOverride);
container.removeEventListener('touchstart', markManualOverride);
};
}, [containerRef, markManualOverride]);
React.useEffect(() => {
return () => {
cancelAnimation();
};
}, [cancelAnimation]);
return React.useMemo(
() => ({
handleScroll,
scrollToPosition,
forceManualMode,
isAtTop,
isManualOverrideActive,
getScrollTop,
getScrollHeight,
getClientHeight,
}),
[
handleScroll,
scrollToPosition,
forceManualMode,
isAtTop,
isManualOverrideActive,
getScrollTop,
getScrollHeight,
getClientHeight,
]
);
};
export type { ScrollEngineResult, ScrollEngineOptions, ScrollOptions };
@@ -0,0 +1,53 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
export type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
export interface SessionActivityResult {
phase: SessionActivityPhase;
isWorking: boolean;
isBusy: boolean;
isCooldown: boolean;
}
const IDLE_RESULT: SessionActivityResult = {
phase: 'idle',
isWorking: false,
isBusy: false,
isCooldown: false,
};
export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult {
const phase = useSessionStore((state) => {
if (!sessionId || !state.sessionActivityPhase) {
return 'idle' as SessionActivityPhase;
}
return state.sessionActivityPhase.get(sessionId) ?? ('idle' as SessionActivityPhase);
});
return React.useMemo<SessionActivityResult>(() => {
if (phase === 'idle') {
return IDLE_RESULT;
}
const isBusy = phase === 'busy';
const isCooldown = phase === 'cooldown';
return {
phase,
isWorking: isBusy || isCooldown,
isBusy,
isCooldown,
};
}, [phase]);
}
export function useCurrentSessionActivity(): SessionActivityResult {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
return useSessionActivity(currentSessionId);
}
@@ -0,0 +1,43 @@
import React from 'react';
import { opencodeClient } from '@/lib/opencode/client';
import { useSessionStore } from '@/stores/useSessionStore';
type SessionStatusPayload = {
type: 'idle' | 'busy' | 'retry';
attempt?: number;
message?: string;
next?: number;
};
export const useSessionStatusBootstrap = () => {
React.useEffect(() => {
let cancelled = false;
const bootstrap = async () => {
try {
const statusMap = await opencodeClient.getSessionStatus();
if (cancelled || !statusMap) return;
const phases = new Map<string, 'idle' | 'busy' | 'cooldown'>();
Object.entries(statusMap).forEach(([sessionId, raw]) => {
if (!sessionId || !raw) return;
const status = raw as SessionStatusPayload;
const phase: 'idle' | 'busy' | 'cooldown' =
status.type === 'busy' || status.type === 'retry' ? 'busy' : 'idle';
phases.set(sessionId, phase);
});
if (phases.size > 0) {
useSessionStore.setState({ sessionActivityPhase: phases });
}
} catch { /* ignored */ }
};
void bootstrap();
return () => {
cancelled = true;
};
}, []);
};
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useState, useCallback } from 'react';
import {
checkForDesktopUpdates,
downloadDesktopUpdate,
restartToApplyUpdate,
isDesktopRuntime,
type UpdateInfo,
type UpdateProgress,
} from '@/lib/desktop';
export type UpdateState = {
checking: boolean;
available: boolean;
downloading: boolean;
downloaded: boolean;
info: UpdateInfo | null;
progress: UpdateProgress | null;
error: string | null;
};
export type UseUpdateCheckReturn = UpdateState & {
checkForUpdates: () => Promise<void>;
downloadUpdate: () => Promise<void>;
restartToUpdate: () => Promise<void>;
dismiss: () => void;
};
const MOCK_UPDATE: UpdateState = {
checking: false,
available: true,
downloading: false,
downloaded: false,
info: {
available: true,
version: '99.0.0-test',
currentVersion: '0.0.0',
body: 'Test update for UI development',
},
progress: null,
error: null,
};
// Set window.__OPENCHAMBER_MOCK_UPDATE__ = true in console to test UI
declare global {
interface Window {
__OPENCHAMBER_MOCK_UPDATE__?: boolean;
}
}
const shouldMockUpdate = (): boolean => {
if (typeof window === 'undefined') return false;
return window.__OPENCHAMBER_MOCK_UPDATE__ === true;
};
export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
const [state, setState] = useState<UpdateState>({
checking: false,
available: false,
downloading: false,
downloaded: false,
info: null,
progress: null,
error: null,
});
const [mockMode, setMockMode] = useState(shouldMockUpdate);
const [mockState, setMockState] = useState<UpdateState>(MOCK_UPDATE);
// Check for mock mode changes (for console toggling)
useEffect(() => {
const interval = setInterval(() => {
const shouldMock = shouldMockUpdate();
if (shouldMock !== mockMode) {
setMockMode(shouldMock);
if (shouldMock) {
setMockState(MOCK_UPDATE);
}
}
}, 500);
return () => clearInterval(interval);
}, [mockMode]);
const checkForUpdates = useCallback(async () => {
if (mockMode) {
setMockState(MOCK_UPDATE);
return;
}
if (!isDesktopRuntime()) {
return;
}
setState((prev) => ({ ...prev, checking: true, error: null }));
try {
const info = await checkForDesktopUpdates();
setState((prev) => ({
...prev,
checking: false,
available: info?.available ?? false,
info,
}));
} catch (error) {
setState((prev) => ({
...prev,
checking: false,
error: error instanceof Error ? error.message : 'Failed to check for updates',
}));
}
}, [mockMode]);
const downloadUpdate = useCallback(async () => {
if (mockMode) {
setMockState((prev) => ({ ...prev, downloading: true }));
// Simulate download progress
let progress = 0;
const interval = setInterval(() => {
progress += 20;
setMockState((prev) => ({
...prev,
progress: { downloaded: progress * 1000, total: 100000 }
}));
if (progress >= 100) {
clearInterval(interval);
setMockState((prev) => ({
...prev,
downloading: false,
downloaded: true,
progress: null,
}));
}
}, 500);
return;
}
if (!isDesktopRuntime() || !state.available) {
return;
}
setState((prev) => ({ ...prev, downloading: true, error: null, progress: null }));
try {
await downloadDesktopUpdate((progress) => {
setState((prev) => ({ ...prev, progress }));
});
setState((prev) => ({ ...prev, downloading: false, downloaded: true }));
} catch (error) {
setState((prev) => ({
...prev,
downloading: false,
error: error instanceof Error ? error.message : 'Failed to download update',
}));
}
}, [mockMode, state.available]);
const restartToUpdate = useCallback(async () => {
if (mockMode) {
return;
}
if (!isDesktopRuntime() || !state.downloaded) {
return;
}
try {
await restartToApplyUpdate();
} catch (error) {
setState((prev) => ({
...prev,
error: error instanceof Error ? error.message : 'Failed to restart',
}));
}
}, [mockMode, state.downloaded]);
const dismiss = useCallback(() => {
if (mockMode) {
setMockState(MOCK_UPDATE);
return;
}
setState((prev) => ({ ...prev, available: false, downloaded: false, info: null }));
}, [mockMode]);
useEffect(() => {
if (checkOnMount && (isDesktopRuntime() || mockMode)) {
const timer = setTimeout(() => {
checkForUpdates();
}, 3000);
return () => clearTimeout(timer);
}
}, [checkOnMount, checkForUpdates, mockMode]);
const currentState = mockMode ? mockState : state;
return {
...currentState,
checkForUpdates,
downloadUpdate,
restartToUpdate,
dismiss,
};
};