mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 14:16:58 +10:00
268 lines
38 KiB
TypeScript
268 lines
38 KiB
TypeScript
|
|
import React, { useCallback, useState } from 'react';
|
||
|
|
import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
|
||
|
|
import { type CodeSession, fetchCodeSessionsFromSessionsAPI } from 'src/utils/teleport/api.js';
|
||
|
|
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow list navigation
|
||
|
|
import { Box, Text, useInput } from '../ink.js';
|
||
|
|
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||
|
|
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js';
|
||
|
|
import { logForDebugging } from '../utils/debug.js';
|
||
|
|
import { detectCurrentRepository } from '../utils/detectRepository.js';
|
||
|
|
import { formatRelativeTime } from '../utils/format.js';
|
||
|
|
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
||
|
|
import { Select } from './CustomSelect/index.js';
|
||
|
|
import { Byline } from './design-system/Byline.js';
|
||
|
|
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';
|
||
|
|
import { Spinner } from './Spinner.js';
|
||
|
|
import { TeleportError } from './TeleportError.js';
|
||
|
|
type Props = {
|
||
|
|
onSelect: (session: CodeSession) => void;
|
||
|
|
onCancel: () => void;
|
||
|
|
isEmbedded?: boolean;
|
||
|
|
};
|
||
|
|
type LoadErrorType = 'network' | 'auth' | 'api' | 'other';
|
||
|
|
const UPDATED_STRING = 'Updated';
|
||
|
|
const SPACE_BETWEEN_TABLE_COLUMNS = ' ';
|
||
|
|
export function ResumeTask({
|
||
|
|
onSelect,
|
||
|
|
onCancel,
|
||
|
|
isEmbedded = false
|
||
|
|
}: Props): React.ReactNode {
|
||
|
|
const {
|
||
|
|
rows
|
||
|
|
} = useTerminalSize();
|
||
|
|
const [sessions, setSessions] = useState<CodeSession[]>([]);
|
||
|
|
const [currentRepo, setCurrentRepo] = useState<string | null>(null);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [loadErrorType, setLoadErrorType] = useState<LoadErrorType | null>(null);
|
||
|
|
const [retrying, setRetrying] = useState(false);
|
||
|
|
const [hasCompletedTeleportErrorFlow, setHasCompletedTeleportErrorFlow] = useState(false);
|
||
|
|
|
||
|
|
// Track focused index for scroll position display in title
|
||
|
|
const [focusedIndex, setFocusedIndex] = useState(1);
|
||
|
|
const escKey = useShortcutDisplay('confirm:no', 'Confirmation', 'Esc');
|
||
|
|
const loadSessions = useCallback(async () => {
|
||
|
|
try {
|
||
|
|
setLoading(true);
|
||
|
|
setLoadErrorType(null);
|
||
|
|
|
||
|
|
// Detect current repository
|
||
|
|
const detectedRepo = await detectCurrentRepository();
|
||
|
|
setCurrentRepo(detectedRepo);
|
||
|
|
logForDebugging(`Current repository: ${detectedRepo || 'not detected'}`);
|
||
|
|
const codeSessions = await fetchCodeSessionsFromSessionsAPI();
|
||
|
|
|
||
|
|
// Filter sessions by current repository if detected
|
||
|
|
let filteredSessions = codeSessions;
|
||
|
|
if (detectedRepo) {
|
||
|
|
filteredSessions = codeSessions.filter(session => {
|
||
|
|
if (!session.repo) return false;
|
||
|
|
const sessionRepo = `${session.repo.owner.login}/${session.repo.name}`;
|
||
|
|
return sessionRepo === detectedRepo;
|
||
|
|
});
|
||
|
|
logForDebugging(`Filtered ${filteredSessions.length} sessions for repo ${detectedRepo} from ${codeSessions.length} total`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort by updated_at (newest first)
|
||
|
|
const sortedSessions = [...filteredSessions].sort((a, b) => {
|
||
|
|
const dateA = new Date(a.updated_at);
|
||
|
|
const dateB = new Date(b.updated_at);
|
||
|
|
return dateB.getTime() - dateA.getTime();
|
||
|
|
});
|
||
|
|
setSessions(sortedSessions);
|
||
|
|
} catch (err) {
|
||
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||
|
|
logForDebugging(`Error loading code sessions: ${errorMessage}`);
|
||
|
|
setLoadErrorType(determineErrorType(errorMessage));
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
setRetrying(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
const handleRetry = () => {
|
||
|
|
setRetrying(true);
|
||
|
|
void loadSessions();
|
||
|
|
};
|
||
|
|
|
||
|
|
// Handle escape via keybinding
|
||
|
|
useKeybinding('confirm:no', onCancel, {
|
||
|
|
context: 'Confirmation'
|
||
|
|
});
|
||
|
|
useInput((input, key) => {
|
||
|
|
// We need to handle ctrl+c in case we don't render a <Select>
|
||
|
|
if (key.ctrl && input === 'c') {
|
||
|
|
onCancel();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle retry in error state with 'ctrl+r'
|
||
|
|
if (key.ctrl && input === 'r' && loadErrorType) {
|
||
|
|
handleRetry();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle enter key for error states to allow continuation with regular teleport
|
||
|
|
if (loadErrorType !== null && key.return) {
|
||
|
|
onCancel(); // This will continue with regular teleport flow
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
const handleErrorComplete = useCallback(() => {
|
||
|
|
setHasCompletedTeleportErrorFlow(true);
|
||
|
|
void loadSessions();
|
||
|
|
}, [setHasCompletedTeleportErrorFlow, loadSessions]);
|
||
|
|
|
||
|
|
// Show error dialog if needed
|
||
|
|
if (!hasCompletedTeleportErrorFlow) {
|
||
|
|
return <TeleportError onComplete={handleErrorComplete} />;
|
||
|
|
}
|
||
|
|
if (loading) {
|
||
|
|
return <Box flexDirection="column" padding={1}>
|
||
|
|
<Box flexDirection="row">
|
||
|
|
<Spinner />
|
||
|
|
<Text bold>Loading Claude Code sessions…</Text>
|
||
|
|
</Box>
|
||
|
|
<Text dimColor>
|
||
|
|
{retrying ? 'Retrying…' : 'Fetching your Claude Code sessions…'}
|
||
|
|
</Text>
|
||
|
|
</Box>;
|
||
|
|
}
|
||
|
|
if (loadErrorType) {
|
||
|
|
return <Box flexDirection="column" padding={1}>
|
||
|
|
<Text bold color="error">
|
||
|
|
Error loading Claude Code sessions
|
||
|
|
</Text>
|
||
|
|
|
||
|
|
{renderErrorSpecificGuidance(loadErrorType)}
|
||
|
|
|
||
|
|
<Text dimColor>
|
||
|
|
Press <Text bold>Ctrl+R</Text> to retry · Press{' '}
|
||
|
|
<Text bold>{escKey}</Text> to cancel
|
||
|
|
</Text>
|
||
|
|
</Box>;
|
||
|
|
}
|
||
|
|
if (sessions.length === 0) {
|
||
|
|
return <Box flexDirection="column" padding={1}>
|
||
|
|
<Text bold>
|
||
|
|
No Claude Code sessions found
|
||
|
|
{currentRepo && <Text> for {currentRepo}</Text>}
|
||
|
|
</Text>
|
||
|
|
<Box marginTop={1}>
|
||
|
|
<Text dimColor>
|
||
|
|
Press <Text bold>{escKey}</Text> to cancel
|
||
|
|
</Text>
|
||
|
|
</Box>
|
||
|
|
</Box>;
|
||
|
|
}
|
||
|
|
const sessionMetadata = sessions.map(session_0 => ({
|
||
|
|
...session_0,
|
||
|
|
timeString: formatRelativeTime(new Date(session_0.updated_at))
|
||
|
|
}));
|
||
|
|
const maxTimeStringLength = Math.max(UPDATED_STRING.length, ...sessionMetadata.map(meta => meta.timeString.length));
|
||
|
|
const options = sessionMetadata.map(({
|
||
|
|
timeString,
|
||
|
|
title,
|
||
|
|
id
|
||
|
|
}) => {
|
||
|
|
const paddedTime = timeString.padEnd(maxTimeStringLength, ' ');
|
||
|
|
|
||
|
|
// TODO: include branch name when API returns it
|
||
|
|
return {
|
||
|
|
label: `${paddedTime} ${title}`,
|
||
|
|
value: id
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// Adjust layout for embedded vs full-screen rendering
|
||
|
|
// Overhead: padding (2) + title (1) + marginY (2) + header (1) + footer (1) = 7
|
||
|
|
const layoutOverhead = 7;
|
||
|
|
const maxVisibleOptions = Math.max(1, isEmbedded ? Math.min(sessions.length, 5, rows - 6 - layoutOverhead) : Math.min(sessions.length, rows - 1 - layoutOverhead));
|
||
|
|
const maxHeight = maxVisibleOptions + layoutOverhead;
|
||
|
|
|
||
|
|
// Show scroll position in title when list needs scrolling
|
||
|
|
const showScrollPosition = sessions.length > maxVisibleOptions;
|
||
|
|
return <Box flexDirection="column" padding={1} height={maxHeight}>
|
||
|
|
<Text bold>
|
||
|
|
Select a session to resume
|
||
|
|
{showScrollPosition && <Text dimColor>
|
||
|
|
{' '}
|
||
|
|
({focusedIndex} of {sessions.length})
|
||
|
|
</Text>}
|
||
|
|
{currentRepo && <Text dimColor> ({currentRepo})</Text>}:
|
||
|
|
</Text>
|
||
|
|
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||
|
|
<Box marginLeft={2}>
|
||
|
|
<Text bold>
|
||
|
|
{UPDATED_STRING.padEnd(maxTimeStringLength, ' ')}
|
||
|
|
{SPACE_BETWEEN_TABLE_COLUMNS}
|
||
|
|
{'Session Title'}
|
||
|
|
</Text>
|
||
|
|
</Box>
|
||
|
|
<Select visibleOptionCount={maxVisibleOptions} options={options} onChange={value => {
|
||
|
|
const session_1 = sessions.find(s => s.id === value);
|
||
|
|
if (session_1) {
|
||
|
|
onSelect(session_1);
|
||
|
|
}
|
||
|
|
}} onFocus={value_0 => {
|
||
|
|
const index = options.findIndex(o => o.value === value_0);
|
||
|
|
if (index >= 0) {
|
||
|
|
setFocusedIndex(index + 1);
|
||
|
|
}
|
||
|
|
}} />
|
||
|
|
</Box>
|
||
|
|
<Box flexDirection="row">
|
||
|
|
<Text dimColor>
|
||
|
|
<Byline>
|
||
|
|
<KeyboardShortcutHint shortcut="↑/↓" action="select" />
|
||
|
|
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||
|
|
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
|
||
|
|
</Byline>
|
||
|
|
</Text>
|
||
|
|
</Box>
|
||
|
|
</Box>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Determines the type of error based on the error message
|
||
|
|
*/
|
||
|
|
function determineErrorType(errorMessage: string): LoadErrorType {
|
||
|
|
const message = errorMessage.toLowerCase();
|
||
|
|
if (message.includes('fetch') || message.includes('network') || message.includes('timeout')) {
|
||
|
|
return 'network';
|
||
|
|
}
|
||
|
|
if (message.includes('auth') || message.includes('token') || message.includes('permission') || message.includes('oauth') || message.includes('not authenticated') || message.includes('/login') || message.includes('console account') || message.includes('403')) {
|
||
|
|
return 'auth';
|
||
|
|
}
|
||
|
|
if (message.includes('api') || message.includes('rate limit') || message.includes('500') || message.includes('529')) {
|
||
|
|
return 'api';
|
||
|
|
}
|
||
|
|
return 'other';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Renders error-specific troubleshooting guidance
|
||
|
|
*/
|
||
|
|
function renderErrorSpecificGuidance(errorType: LoadErrorType): React.ReactNode {
|
||
|
|
switch (errorType) {
|
||
|
|
case 'network':
|
||
|
|
return <Box marginY={1} flexDirection="column">
|
||
|
|
<Text dimColor>Check your internet connection</Text>
|
||
|
|
</Box>;
|
||
|
|
case 'auth':
|
||
|
|
return <Box marginY={1} flexDirection="column">
|
||
|
|
<Text dimColor>Teleport requires a Claude account</Text>
|
||
|
|
<Text dimColor>
|
||
|
|
Run <Text bold>/login</Text> and select "Claude account with
|
||
|
|
subscription"
|
||
|
|
</Text>
|
||
|
|
</Box>;
|
||
|
|
case 'api':
|
||
|
|
return <Box marginY={1} flexDirection="column">
|
||
|
|
<Text dimColor>Sorry, Claude encountered an error</Text>
|
||
|
|
</Box>;
|
||
|
|
case 'other':
|
||
|
|
return <Box marginY={1} flexDirection="row">
|
||
|
|
<Text dimColor>Sorry, Claude Code encountered an error</Text>
|
||
|
|
</Box>;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUNhbGxiYWNrIiwidXNlU3RhdGUiLCJ1c2VUZXJtaW5hbFNpemUiLCJDb2RlU2Vzc2lvbiIsImZldGNoQ29kZVNlc3Npb25zRnJvbVNlc3Npb25zQVBJIiwiQm94IiwiVGV4dCIsInVzZUlucHV0IiwidXNlS2V5YmluZGluZyIsInVzZVNob3J0Y3V0RGlzcGxheSIsImxvZ0ZvckRlYnVnZ2luZyIsImRldGVjdEN1cnJlbnRSZXBvc2l0b3J5IiwiZm9ybWF0UmVsYXRpdmVUaW1lIiwiQ29uZmlndXJhYmxlU2hvcnRjdXRIaW50IiwiU2VsZWN0IiwiQnlsaW5lIiwiS2V5Ym9hcmRTaG9ydGN1dEhpbnQiLCJTcGlubmVyIiwiVGVsZXBvcnRFcnJvciIsIlByb3BzIiwib25TZWxlY3QiLCJzZXNzaW9uIiwib25DYW5jZWwiLCJpc0VtYmVkZGVkIiwiTG9hZEVycm9yVHlwZSIsIlVQREFURURfU1RSSU5HIiwiU1BBQ0VfQkVUV0VFTl9UQUJMRV9DT0xVTU5TIiwiUmVzdW1lVGFzayIsIlJlYWN0Tm9kZSIsInJvd3MiLCJzZXNzaW9ucyIsInNldFNlc3Npb25zIiwiY3VycmVudFJlcG8iLCJzZXRDdXJyZW50UmVwbyIsImxvYWRpbmciLCJzZXRMb2FkaW5nIiwibG9hZEVycm9yVHlwZSIsInNldExvYWRFcnJvclR5cGUiLCJyZXRyeWluZyIsInNldFJldHJ5aW5nIiwiaGFzQ29tcGxldGVkVGVsZXBvcnRFcnJvckZsb3ciLCJzZXRIYXNDb21wbGV0ZWRUZWxlcG9ydEVycm9yRmxvdyIsImZvY3VzZWRJbmRleCIsInNldEZvY3VzZWRJbmRleCIsImVzY0tleSIsImxvYWRTZXNzaW9ucyIsImRldGVjdGVkUmVwbyIsImNvZGVTZXNzaW9ucyIsImZpbHRlcmVkU2Vzc2lvbnMiLCJmaWx0ZXIiLCJyZXBvIiwic2Vzc2lvblJlcG8iLCJvd25lciIsImxvZ2luIiwibmFtZSIsImxlbmd0aCIsInNvcnRlZFNlc3Npb25zIiwic29ydCIsImEiLCJiIiwiZGF0ZUEiLCJEYXRlIiwidXBkYXRlZF9hdCIsImRhdGVCIiwiZ2V0VGltZSIsImVyciIsImVycm9yTWVzc2FnZSIsIkVycm9yIiwibWVzc2FnZSIsIlN0cmluZyIsImRldGVybWluZUVycm9yVHlwZSIsImhhbmRsZVJldHJ5IiwiY29udGV4dCIsImlucHV0Iiwia2V5IiwiY3RybCIsInJldHVybiIsImhhbmRsZUVycm9yQ29tcGxldGUiLCJyZW5kZXJFcnJvclNwZWNpZmljR3VpZGFuY2UiLCJzZXNzaW9uTWV0YWRhdGEiLCJtYXAiLCJ0aW1lU3RyaW5nIiwibWF4VGltZVN0cmluZ0xlbmd0aCIsIk1hdGgiLCJtYXgiLCJtZXRhIiwib3B0aW9ucyIsInRpdGxlIiwiaWQiLCJwYWRkZWRUaW1lIiwicGFkRW5kIiwibGFiZWwiLCJ2YWx1ZSIsImxheW91dE92ZXJoZWFkIiwibWF4VmlzaWJsZU9wdGlvbnMiLCJtaW4iLCJtYXhIZWlnaHQiLCJzaG93U2Nyb2xsUG9zaXRpb24iLCJmaW5kIiwicyIsImluZGV4IiwiZmluZEluZGV4IiwibyIsInRvTG93ZXJDYXNlIiwiaW5jbHVkZXMiLCJlcnJvclR5cGUiXSwic291cmNlcyI6WyJSZXN1bWVUYXNrLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHsgdXNlQ2FsbGJhY2ssIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VUZXJtaW5hbFNpemUgfSBmcm9tICdzcmMvaG9va3MvdXNlVGVybWluYWxTaXplLmpzJ1xuaW1wb3J0IHtcbiAgdHlwZSBDb2RlU2Vzc2lvbixcbiAgZmV0Y2hDb2RlU2Vzc2lvbnNGcm9tU2Vzc2lvbnNBUEksXG59IGZyb20gJ3NyYy91dGlscy90ZWxlcG9ydC9hcGkuanMnXG4vLyBlc2xpbnQtZGlzYWJsZS1uZXh0LWxpbmUgY3VzdG9tLXJ1bGVzL3ByZWZlci11c2Uta2V5YmluZGluZ3MgLS0gcmF3IGovay9hcnJvdyBsaXN0IG5hdmlnYXRpb25cbmltcG9ydCB7IEJveCwgVGV4dCwgdXNlSW5wdXQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgeyB1c2VLZXliaW5kaW5nIH0gZnJvbSAnLi4va2V5YmluZGluZ3MvdXNlS2V5YmluZGluZy5qcydcbmltcG9ydCB7IHVzZVNob3J0Y3V0RGlzcGxheSB9IGZyb20gJy4uL2tleWJpbmRpbmdzL3VzZVNob3J0Y3V0RGlzcGxheS5qcydcbmltcG9ydCB7IGxvZ0ZvckRlYnVnZ2luZyB9IGZyb20gJy4uL3V0aWxzL2RlYnVnLmpzJ1xuaW1wb3J0IHsgZGV0ZWN0Q3VycmVudFJlcG9zaXRvcnkgfSBmcm9tICcuLi91dGlscy9kZXRlY3RSZXBvc2l0b3J5LmpzJ1xuaW1wb3J0IHsgZm9ybWF0UmVsYXRpdmVUaW1lIH0gZnJvbSAnLi4vdXRpbHMvZm9ybWF0LmpzJ1xuaW1wb3J0IHsgQ29uZmlndXJhYmxlU2hvcnRjdXRIaW50IH0gZnJvbSAnLi9Db25maWd1cmFibGVTaG9ydGN1dEhpbnQuanMnXG5pbXBvcnQgeyBTZWxlY3QgfSBmcm9tICcuL0N1c3RvbVNlbGVjdC9pbmRleC5qcydcbmltcG9ydCB7IEJ5bGluZSB9IGZyb20gJy4vZGVzaWduLXN5c3RlbS9CeWxpbmUuanMnXG5pbXBvcnQgeyBLZXlib2FyZFNob3J0Y3V0SGludCB9IGZyb20gJy4vZGVzaWduLXN5c3RlbS9LZXlib2FyZFNob3J0Y3V0SGludC5qcydcbmltcG9ydCB7IFNwaW5uZXIgfSBmcm9tICcuL1NwaW5uZXIuanMnXG5pbXBvcnQgeyBUZWxlcG9ydEVycm9yIH0gZnJvbSAnLi9UZWxlcG9ydEVycm9yLmpzJ1xuXG50eXBlIFByb3BzID0ge1xuICBvblNlbGVjdDogKHNlc3Npb246IENvZGVTZXNzaW9uKSA9PiB2b2lkXG4gIG9uQ2FuY2VsOiAoKSA9PiB2b2lkXG4gIGlzRW1iZWRkZWQ/OiBib29sZWFuXG59XG5cbnR5cGUgTG9hZEVycm9yVHlwZSA9ICduZXR3b3JrJyB8ICdhdXRoJyB8ICdhcGknIHwgJ290aGVyJ1xuXG5jb25zdCBVUERBVEVEX1NUUklORyA9ICdVcGRhdGVkJ1xuY29uc3QgU1BBQ0VfQkVUV0VFTl9UQUJMRV9DT0xVTU5TID0gJyAgJ1xuXG5leHBvcnQgZnVuY3Rpb24gUmVzdW1lVGFzayh7XG4gIG9uU2VsZWN0LFxuICBvbkNhbmNlbCxcbiAgaXNFbWJlZGRlZCA9IGZhbHNlLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCB7IHJvd3MgfSA9IHVzZVRlcm1pbmFsU2l6ZSgpXG4gIGNvbnN0IFtzZXNzaW9ucywgc2V0U2Vzc2lvbnNdID0gdXNlU3RhdGU8Q29kZVNlc3Npb25bXT4oW10pXG4gIGNvbnN0IFtjdXJyZW50UmVwbywgc2V0Q3VycmVudFJlcG9dID0gdXNlU3RhdGU8c3RyaW5nIHwgbnVsbD4obnV
|