chore: remove dead code (59 unused files + ~125 unused exports) (#1835)
* chore: remove dead/unreferenced files across ui, vscode Remove 59 unused source files (components, hooks, lib utils, stores, barrels, and orphaned vscode github modules) that are not imported by any entry-reachable code. Also drop a stale test mock for the removed execCommands module. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove unused exported symbols (types, functions, consts, hooks) Remove exported symbols whose identifier is referenced nowhere in the repository (verified via repo-wide search), across ui types/contracts, lib utilities, sync layer, stores, and components. Also drop the few imports/private helpers orphaned by these removals. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove more unused exports (desktop, shortcuts, worktree, vscode) Continue removing repo-wide unreferenced exported functions, consts and types across lib/desktop, shortcuts, worktreeSessionCreator, sync, and vscode gitService, with cascading orphaned helpers/imports cleaned up. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: add dead-code cleanup tooling * refactor: checkpoint dead-code cleanup * refactor: remove dead-code suppressions --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
Bohdan Triapitsyn
parent
4a37b9a005
commit
00821700de
@@ -7,7 +7,7 @@ import { useDirectorySync, useSessionPermissions, useSessionQuestions, useSessio
|
||||
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
||||
import { useCurrentSessionActivity } from './useSessionActivity';
|
||||
|
||||
export type AssistantActivity = 'idle' | 'streaming' | 'tooling' | 'cooldown' | 'permission';
|
||||
type AssistantActivity = 'idle' | 'streaming' | 'tooling' | 'cooldown' | 'permission';
|
||||
|
||||
interface WorkingSummary {
|
||||
activity: AssistantActivity;
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
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]);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
|
||||
export type AutoFollowState = 'following' | 'released';
|
||||
type AutoFollowState = 'following' | 'released';
|
||||
|
||||
export type ContentChangeReason = 'text' | 'structural' | 'permission';
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import React from 'react';
|
||||
import { animate } from 'motion/react';
|
||||
import { useOptionalDrawer } from '@/contexts/DrawerContext';
|
||||
|
||||
type DrawerSwipeOptions = {
|
||||
edgeSide?: 'left' | 'right';
|
||||
strictHorizontalIntent?: boolean;
|
||||
horizontalIntentRatio?: number;
|
||||
activationDistance?: number;
|
||||
onlyWhenClosed?: boolean;
|
||||
};
|
||||
|
||||
export function useDrawerSwipe(options: DrawerSwipeOptions = {}) {
|
||||
const drawer = useOptionalDrawer();
|
||||
const {
|
||||
edgeSide,
|
||||
strictHorizontalIntent = false,
|
||||
horizontalIntentRatio = 1.35,
|
||||
activationDistance = 30,
|
||||
onlyWhenClosed = false,
|
||||
} = options;
|
||||
const touchStartXRef = React.useRef(0);
|
||||
const touchStartYRef = React.useRef(0);
|
||||
const isHorizontalSwipeRef = React.useRef<boolean | null>(null);
|
||||
const isDraggingDrawerRef = React.useRef<'left' | 'right' | null>(null);
|
||||
|
||||
const handleTouchStart = React.useCallback((e: React.TouchEvent) => {
|
||||
if (!drawer) return;
|
||||
touchStartXRef.current = e.touches[0].clientX;
|
||||
touchStartYRef.current = e.touches[0].clientY;
|
||||
isHorizontalSwipeRef.current = null;
|
||||
isDraggingDrawerRef.current = null;
|
||||
}, [drawer]);
|
||||
|
||||
const handleTouchMove = React.useCallback((e: React.TouchEvent) => {
|
||||
if (!drawer) return;
|
||||
const currentX = e.touches[0].clientX;
|
||||
const currentY = e.touches[0].clientY;
|
||||
const deltaX = currentX - touchStartXRef.current;
|
||||
const deltaY = currentY - touchStartYRef.current;
|
||||
|
||||
if (isHorizontalSwipeRef.current === null) {
|
||||
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
|
||||
if (strictHorizontalIntent) {
|
||||
isHorizontalSwipeRef.current = Math.abs(deltaX) > Math.abs(deltaY) * horizontalIntentRatio;
|
||||
} else {
|
||||
isHorizontalSwipeRef.current = Math.abs(deltaX) > Math.abs(deltaY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isHorizontalSwipeRef.current === true) {
|
||||
if (onlyWhenClosed && (drawer.leftDrawerOpen || drawer.rightDrawerOpen)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leftDrawerWidthPx = drawer.leftDrawerWidth.current || window.innerWidth * 0.85;
|
||||
const rightDrawerWidthPx = drawer.rightDrawerWidth.current || window.innerWidth * 0.85;
|
||||
|
||||
if (isDraggingDrawerRef.current === null) {
|
||||
if (!edgeSide && drawer.leftDrawerOpen && deltaX > 10) {
|
||||
isDraggingDrawerRef.current = 'left';
|
||||
} else if (!edgeSide && drawer.rightDrawerOpen && deltaX < -10) {
|
||||
isDraggingDrawerRef.current = 'right';
|
||||
} else if (!drawer.leftDrawerOpen && !drawer.rightDrawerOpen) {
|
||||
if (edgeSide === 'left') {
|
||||
if (deltaX > activationDistance) {
|
||||
isDraggingDrawerRef.current = 'left';
|
||||
}
|
||||
} else if (edgeSide === 'right') {
|
||||
if (deltaX < -activationDistance) {
|
||||
isDraggingDrawerRef.current = 'right';
|
||||
}
|
||||
} else if (deltaX > activationDistance) {
|
||||
isDraggingDrawerRef.current = 'left';
|
||||
} else if (deltaX < -activationDistance) {
|
||||
isDraggingDrawerRef.current = 'right';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDraggingDrawerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (isDraggingDrawerRef.current === 'left') {
|
||||
if (drawer.leftDrawerOpen) {
|
||||
const progress = Math.max(0, Math.min(1, deltaX / leftDrawerWidthPx));
|
||||
drawer.leftDrawerX.set(-leftDrawerWidthPx * (1 - progress));
|
||||
} else {
|
||||
const progress = Math.max(0, Math.min(1, deltaX / leftDrawerWidthPx));
|
||||
drawer.leftDrawerX.set(-leftDrawerWidthPx + (leftDrawerWidthPx * progress));
|
||||
}
|
||||
}
|
||||
|
||||
if (isDraggingDrawerRef.current === 'right') {
|
||||
if (drawer.rightDrawerOpen) {
|
||||
const progress = Math.max(0, Math.min(1, -deltaX / rightDrawerWidthPx));
|
||||
drawer.rightDrawerX.set(rightDrawerWidthPx * (1 - progress));
|
||||
} else {
|
||||
const progress = Math.max(0, Math.min(1, -deltaX / rightDrawerWidthPx));
|
||||
drawer.rightDrawerX.set(rightDrawerWidthPx - (rightDrawerWidthPx * progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activationDistance, drawer, edgeSide, horizontalIntentRatio, onlyWhenClosed, strictHorizontalIntent]);
|
||||
|
||||
const handleTouchEnd = React.useCallback((e: React.TouchEvent) => {
|
||||
if (!drawer) return;
|
||||
if (isHorizontalSwipeRef.current !== true) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const deltaX = endX - touchStartXRef.current;
|
||||
const velocityThreshold = 500;
|
||||
const progressThreshold = 0.3;
|
||||
|
||||
const leftDrawerWidthPx = drawer.leftDrawerWidth.current || window.innerWidth * 0.85;
|
||||
const rightDrawerWidthPx = drawer.rightDrawerWidth.current || window.innerWidth * 0.85;
|
||||
|
||||
if (isDraggingDrawerRef.current === 'left') {
|
||||
const isOpen = drawer.leftDrawerOpen;
|
||||
const currentX = drawer.leftDrawerX.get();
|
||||
const progress = isOpen
|
||||
? 1 - Math.abs(currentX) / leftDrawerWidthPx
|
||||
: 1 + currentX / leftDrawerWidthPx;
|
||||
|
||||
const shouldComplete = progress > progressThreshold || Math.abs(deltaX * 10) > velocityThreshold;
|
||||
|
||||
if (shouldComplete) {
|
||||
const targetX = isOpen ? -leftDrawerWidthPx : 0;
|
||||
animate(drawer.leftDrawerX, targetX, {
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 35,
|
||||
mass: 0.8,
|
||||
});
|
||||
drawer.setMobileLeftDrawerOpen(!isOpen);
|
||||
} else {
|
||||
const targetX = isOpen ? 0 : -leftDrawerWidthPx;
|
||||
animate(drawer.leftDrawerX, targetX, {
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 35,
|
||||
mass: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
isDraggingDrawerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDraggingDrawerRef.current === 'right') {
|
||||
const isOpen = drawer.rightDrawerOpen;
|
||||
const currentX = drawer.rightDrawerX.get();
|
||||
const progress = isOpen
|
||||
? 1 - Math.abs(currentX) / rightDrawerWidthPx
|
||||
: 1 - currentX / rightDrawerWidthPx;
|
||||
|
||||
const shouldComplete = progress > progressThreshold || Math.abs(deltaX * 10) > velocityThreshold;
|
||||
|
||||
if (shouldComplete) {
|
||||
const targetX = isOpen ? rightDrawerWidthPx : 0;
|
||||
animate(drawer.rightDrawerX, targetX, {
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 35,
|
||||
mass: 0.8,
|
||||
});
|
||||
drawer.setRightSidebarOpen(!isOpen);
|
||||
} else {
|
||||
const targetX = isOpen ? 0 : rightDrawerWidthPx;
|
||||
animate(drawer.rightDrawerX, targetX, {
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 35,
|
||||
mass: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
isDraggingDrawerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isHorizontalSwipeRef.current = null;
|
||||
}, [drawer]);
|
||||
|
||||
return {
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
};
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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 = useUIStore((state) => state.isMobile);
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
|
||||
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;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
export type GitmojiEntry = {
|
||||
type GitmojiEntry = {
|
||||
emoji: string;
|
||||
code: string;
|
||||
description: string;
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
type LongPressOptions = {
|
||||
delay?: number;
|
||||
onLongPress: () => void;
|
||||
onTap?: () => void;
|
||||
enableHaptic?: boolean;
|
||||
};
|
||||
|
||||
export function useLongPress({
|
||||
delay = 500,
|
||||
onLongPress,
|
||||
onTap,
|
||||
enableHaptic = true,
|
||||
}: LongPressOptions) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isLongPressRef = useRef(false);
|
||||
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
startPosRef.current = null;
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent | React.TouchEvent) => {
|
||||
clear();
|
||||
isLongPressRef.current = false;
|
||||
|
||||
// Store start position to detect movement
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.PointerEvent).clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.PointerEvent).clientY;
|
||||
startPosRef.current = { x: clientX, y: clientY };
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
isLongPressRef.current = true;
|
||||
if (enableHaptic && typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
try {
|
||||
navigator.vibrate(15);
|
||||
} catch {
|
||||
// Ignore vibration errors
|
||||
}
|
||||
}
|
||||
onLongPress();
|
||||
}, delay);
|
||||
}, [clear, delay, onLongPress, enableHaptic]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent | React.TouchEvent) => {
|
||||
if (!startPosRef.current || !timerRef.current) return;
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.PointerEvent).clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.PointerEvent).clientY;
|
||||
|
||||
// If moved more than 10px, cancel long press
|
||||
const dx = Math.abs(clientX - startPosRef.current.x);
|
||||
const dy = Math.abs(clientY - startPosRef.current.y);
|
||||
|
||||
if (dx > 10 || dy > 10) {
|
||||
clear();
|
||||
}
|
||||
}, [clear]);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (!isLongPressRef.current && onTap) {
|
||||
// Only trigger tap if we didn't drag too far (checked in move)
|
||||
// and didn't trigger long press
|
||||
onTap();
|
||||
}
|
||||
}
|
||||
clear();
|
||||
}, [clear, onTap]);
|
||||
|
||||
const onPointerLeave = useCallback(() => {
|
||||
clear();
|
||||
}, [clear]);
|
||||
|
||||
const onContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
// Prevent default context menu on long press
|
||||
if (isLongPressRef.current) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => clear, [clear]);
|
||||
|
||||
return {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerLeave,
|
||||
onTouchStart: onPointerDown, // Add touch handlers for better mobile support
|
||||
onTouchMove: onPointerMove,
|
||||
onTouchEnd: onPointerUp,
|
||||
onTouchCancel: clear,
|
||||
onContextMenu,
|
||||
};
|
||||
}
|
||||
@@ -63,7 +63,7 @@ const resolveProviderLogoSrc = (providerId: string | null | undefined): string |
|
||||
return remoteResolvedId ? `https://models.dev/logos/${remoteResolvedId}.svg` : null;
|
||||
};
|
||||
|
||||
export const preloadProviderLogo = (providerId: string | null | undefined): void => {
|
||||
const preloadProviderLogo = (providerId: string | null | undefined): void => {
|
||||
if (typeof Image === 'undefined') return;
|
||||
const src = resolveProviderLogoSrc(providerId);
|
||||
if (!src || PRELOADED_LOGO_SRCS.has(src)) return;
|
||||
|
||||
@@ -246,102 +246,3 @@ export function useRouter(): void {
|
||||
};
|
||||
}, [applyRoute, isVSCode, setActiveMainTab, setSettingsDialogOpen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatically navigate to a route.
|
||||
* Can be used from outside React components.
|
||||
*/
|
||||
export function navigateToRoute(route: Partial<RouteState>): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check VS Code context
|
||||
const win = window as { __VSCODE_CONFIG__?: unknown };
|
||||
if (win.__VSCODE_CONFIG__ !== undefined) {
|
||||
// In VS Code, just apply state changes directly
|
||||
if (route.sessionId) {
|
||||
void useSessionUIStore.getState().setCurrentSession(route.sessionId);
|
||||
}
|
||||
if (route.settingsPath) {
|
||||
useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
|
||||
useUIStore.getState().setSettingsDialogOpen(true);
|
||||
} else if (route.tab) {
|
||||
useUIStore.getState().setActiveMainTab(route.tab);
|
||||
}
|
||||
if (route.diffFile) {
|
||||
useUIStore.getState().navigateToDiff(route.diffFile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build URL and navigate
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (route.sessionId) {
|
||||
params.set('session', route.sessionId);
|
||||
}
|
||||
if (route.settingsPath) {
|
||||
params.set('settings', route.settingsPath);
|
||||
} else if (route.tab && route.tab !== 'chat') {
|
||||
if (useUIStore.getState().isSettingsDialogOpen) {
|
||||
useUIStore.getState().setSettingsDialogOpen(false);
|
||||
}
|
||||
params.set('tab', route.tab);
|
||||
}
|
||||
if (route.diffFile) {
|
||||
params.set('file', route.diffFile);
|
||||
}
|
||||
|
||||
const search = params.toString();
|
||||
const url = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||
|
||||
window.history.pushState({ route }, '', url);
|
||||
|
||||
// Also apply to state
|
||||
if (route.sessionId) {
|
||||
void useSessionUIStore.getState().setCurrentSession(route.sessionId);
|
||||
}
|
||||
if (route.settingsPath) {
|
||||
useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
|
||||
useUIStore.getState().setSettingsDialogOpen(true);
|
||||
} else if (route.tab) {
|
||||
useUIStore.getState().setActiveMainTab(route.tab);
|
||||
}
|
||||
if (route.diffFile) {
|
||||
useUIStore.getState().navigateToDiff(route.diffFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shareable URL for the current state.
|
||||
*/
|
||||
export function getShareableURL(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const sessionState = useSessionUIStore.getState();
|
||||
const uiState = useUIStore.getState();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (sessionState.currentSessionId) {
|
||||
params.set('session', sessionState.currentSessionId);
|
||||
}
|
||||
|
||||
if (uiState.isSettingsDialogOpen) {
|
||||
params.set('settings', uiState.settingsPage || 'home');
|
||||
} else if (uiState.activeMainTab !== 'chat') {
|
||||
params.set('tab', uiState.activeMainTab);
|
||||
}
|
||||
|
||||
if (uiState.activeMainTab === 'diff' && uiState.pendingDiffFile) {
|
||||
params.set('file', uiState.pendingDiffFile);
|
||||
}
|
||||
|
||||
const search = params.toString();
|
||||
const base = `${window.location.origin}${window.location.pathname}`;
|
||||
|
||||
return search ? `${base}?${search}` : base;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const useRuntimeAPIs = (): RuntimeAPIs => {
|
||||
return apis;
|
||||
};
|
||||
|
||||
export const useRuntimeAPI = <TValue,>(selector: RuntimeAPISelector<TValue>): TValue => {
|
||||
const useRuntimeAPI = <TValue,>(selector: RuntimeAPISelector<TValue>): TValue => {
|
||||
const apis = useRuntimeAPIs();
|
||||
return selector(apis);
|
||||
};
|
||||
|
||||
@@ -100,7 +100,7 @@ export interface UseSayTTSReturn {
|
||||
unlockAudio: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SpeakOptions {
|
||||
interface SpeakOptions {
|
||||
/** Voice to use (defaults to Samantha) */
|
||||
voice?: string;
|
||||
/** Speech rate in words per minute (defaults to 200) */
|
||||
|
||||
@@ -84,7 +84,7 @@ export interface UseServerTTSReturn {
|
||||
unlockAudio: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SpeakOptions {
|
||||
interface SpeakOptions {
|
||||
/** Voice to use (defaults to coral) */
|
||||
voice?: string;
|
||||
/** Model to use (defaults to gpt-4o-mini-tts) */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionStatus, useSessionMessages, useSessionPermissions, useSessionQuestions } from '@/sync/sync-context';
|
||||
|
||||
// Mirrors OpenCode SessionStatus: busy|retry|idle.
|
||||
export type SessionActivityPhase = 'idle' | 'busy' | 'retry';
|
||||
type SessionActivityPhase = 'idle' | 'busy' | 'retry';
|
||||
|
||||
export interface SessionActivityResult {
|
||||
phase: SessionActivityPhase;
|
||||
@@ -27,7 +27,7 @@ const IDLE_RESULT: SessionActivityResult = {
|
||||
* question indicator takes priority, and the send button must stay available so
|
||||
* the user can supersede the prompt with a new message).
|
||||
*/
|
||||
export function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult {
|
||||
function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult {
|
||||
const status = useSessionStatus(sessionId ?? '', directory);
|
||||
const messages = useSessionMessages(sessionId ?? '', directory);
|
||||
const permissions = useSessionPermissions(sessionId ?? '', directory);
|
||||
|
||||
@@ -22,7 +22,7 @@ type BuildAutoDeleteCandidatesOptions = {
|
||||
now?: number;
|
||||
};
|
||||
|
||||
export const buildAutoDeleteCandidates = ({
|
||||
const buildAutoDeleteCandidates = ({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
cutoffDays,
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useState, useRef, useEffect, useMemo } from "react"
|
||||
|
||||
type StageConfig = {
|
||||
/** How many messages to show on first paint */
|
||||
init: number
|
||||
/** How many to add per animation frame */
|
||||
batch: number
|
||||
}
|
||||
|
||||
type UseTimelineStagingInput<T> = {
|
||||
/** Key that changes when session switches */
|
||||
sessionKey: string
|
||||
/** All messages (sorted) */
|
||||
messages: T[]
|
||||
/** Config for staging behavior */
|
||||
config?: StageConfig
|
||||
}
|
||||
|
||||
type UseTimelineStagingResult<T> = {
|
||||
/** The subset of messages that should be rendered */
|
||||
stagedMessages: T[]
|
||||
/** Whether staging is still in progress */
|
||||
isStaging: boolean
|
||||
/** Force the current session timeline to render fully now */
|
||||
completeNow: () => boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: StageConfig = { init: 1, batch: 3 }
|
||||
|
||||
/**
|
||||
* Defer-mounts small timeline windows so revealing older turns does not
|
||||
* block first paint with a large DOM mount.
|
||||
*
|
||||
* Once staging completes for a session it never re-stages — backfill and
|
||||
* new messages render immediately.
|
||||
*
|
||||
* Defers mounting older turns so first paint isn't blocked by large DOM.
|
||||
*/
|
||||
export function useTimelineStaging<T>(
|
||||
input: UseTimelineStagingInput<T>,
|
||||
): UseTimelineStagingResult<T> {
|
||||
const config = input.config ?? DEFAULT_CONFIG
|
||||
const { sessionKey, messages } = input
|
||||
|
||||
const [stagedCount, setStagedCount] = useState(() => messages.length)
|
||||
const completedSessions = useRef(new Set<string>())
|
||||
const activeSession = useRef("")
|
||||
const frameRef = useRef<number | null>(null)
|
||||
|
||||
const completeNow = () => {
|
||||
if (!sessionKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
|
||||
activeSession.current = ""
|
||||
completedSessions.current.add(sessionKey)
|
||||
|
||||
const total = messages.length
|
||||
let changed = false
|
||||
setStagedCount((previous) => {
|
||||
if (previous === total) {
|
||||
return previous
|
||||
}
|
||||
changed = true
|
||||
return total
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel any pending animation frame
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
|
||||
const total = messages.length
|
||||
|
||||
// If already completed for this session, show all immediately
|
||||
if (completedSessions.current.has(sessionKey)) {
|
||||
setStagedCount(total)
|
||||
return
|
||||
}
|
||||
|
||||
// Small message list — no staging needed
|
||||
if (total <= config.init) {
|
||||
setStagedCount(total)
|
||||
completedSessions.current.add(sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
// Start staging
|
||||
activeSession.current = sessionKey
|
||||
let count = Math.min(total, config.init)
|
||||
setStagedCount(count)
|
||||
|
||||
const step = () => {
|
||||
// Session changed mid-staging — bail
|
||||
if (activeSession.current !== sessionKey) {
|
||||
frameRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
count = Math.min(messages.length, count + config.batch)
|
||||
setStagedCount(count)
|
||||
|
||||
if (count >= messages.length) {
|
||||
completedSessions.current.add(sessionKey)
|
||||
activeSession.current = ""
|
||||
frameRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(step)
|
||||
|
||||
return () => {
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
}
|
||||
}, [sessionKey, messages.length, config.init, config.batch])
|
||||
|
||||
const stagedMessages = useMemo(() => {
|
||||
if (stagedCount >= messages.length) return messages
|
||||
return messages.slice(Math.max(0, messages.length - stagedCount))
|
||||
}, [messages, stagedCount])
|
||||
|
||||
const isStaging = activeSession.current === sessionKey &&
|
||||
!completedSessions.current.has(sessionKey)
|
||||
|
||||
return { stagedMessages, isStaging, completeNow }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getVisualViewportState } from './useVisualViewport';
|
||||
|
||||
const withWindow = (value: { innerHeight: number; visualViewport?: { height: number } | null }, run: () => void) => {
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe('getVisualViewportState', () => {
|
||||
test('falls back to innerHeight when visualViewport is unavailable', () => {
|
||||
withWindow({ innerHeight: 812 }, () => {
|
||||
expect(getVisualViewportState()).toEqual({ height: 812, keyboardHeight: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
test('derives keyboard height from visualViewport height', () => {
|
||||
withWindow({ innerHeight: 812, visualViewport: { height: 512 } }, () => {
|
||||
expect(getVisualViewportState()).toEqual({ height: 512, keyboardHeight: 300 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface VisualViewportState {
|
||||
height: number;
|
||||
keyboardHeight: number;
|
||||
}
|
||||
|
||||
export const getVisualViewportState = (): VisualViewportState => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { height: 0, keyboardHeight: 0 };
|
||||
}
|
||||
|
||||
const height = window.visualViewport?.height ?? window.innerHeight;
|
||||
const keyboardHeight = window.visualViewport
|
||||
? Math.max(0, window.innerHeight - height)
|
||||
: 0;
|
||||
|
||||
return { height, keyboardHeight };
|
||||
};
|
||||
|
||||
export const useVisualViewport = (): VisualViewportState => {
|
||||
const [state, setState] = React.useState<VisualViewportState>(getVisualViewportState);
|
||||
|
||||
const rafIdRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const visualViewport = window.visualViewport;
|
||||
|
||||
const handleChange = () => {
|
||||
if (rafIdRef.current !== null) return;
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
const nextState = getVisualViewportState();
|
||||
setState((prev) => {
|
||||
if (prev.height === nextState.height && prev.keyboardHeight === nextState.keyboardHeight) return prev;
|
||||
return nextState;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (visualViewport) {
|
||||
visualViewport.addEventListener('resize', handleChange);
|
||||
visualViewport.addEventListener('scroll', handleChange, { passive: true });
|
||||
} else {
|
||||
window.addEventListener('resize', handleChange);
|
||||
}
|
||||
handleChange();
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
}
|
||||
if (visualViewport) {
|
||||
visualViewport.removeEventListener('resize', handleChange);
|
||||
visualViewport.removeEventListener('scroll', handleChange);
|
||||
} else {
|
||||
window.removeEventListener('resize', handleChange);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
Reference in New Issue
Block a user