Initial public release
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user