Skip to content

Commit 910d971

Browse files
Merge pull request #86 from ai-action/fix/check
2 parents 867f61d + 5d7e0b6 commit 910d971

25 files changed

Lines changed: 812 additions & 143 deletions

src/components/App/App.test.tsx

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const deleteSessionIfEmpty = vi.hoisted(() => vi.fn());
2929
const appendMessage = vi.hoisted(() => vi.fn());
3030
const updateSessionModel = vi.hoisted(() => vi.fn());
3131
const saveConfig = vi.hoisted(() => vi.fn());
32+
const listModels = vi.hoisted(() => vi.fn());
3233

3334
vi.mock('@/utils', async () => ({
3435
...(await vi.importActual('@/utils')),
@@ -44,6 +45,9 @@ vi.mock('@/utils', async () => ({
4445
})),
4546
saveConfig,
4647
},
48+
ollama: {
49+
listModels,
50+
},
4751
screen: {
4852
clear: clearScreen,
4953
},
@@ -193,6 +197,34 @@ vi.mock('@/components/SessionManager', () => ({
193197
},
194198
}));
195199

200+
vi.mock('./ReadinessCheck', async () => {
201+
const actual =
202+
await vi.importActual<typeof import('./ReadinessCheck')>(
203+
'./ReadinessCheck',
204+
);
205+
206+
return {
207+
...actual,
208+
ReadinessCheck: (props: {
209+
errorMessage?: string | null;
210+
onCommand: (command: string) => void;
211+
setupState: string;
212+
}) => {
213+
const { errorMessage, onCommand, setupState } = props;
214+
capturedCallbacks.onCommand = onCommand;
215+
const message =
216+
setupState === 'missing-model-config'
217+
? 'Select or download a model'
218+
: setupState === 'no-installed-models'
219+
? 'Download a model'
220+
: errorMessage
221+
? `Unable to load models: ${errorMessage}`
222+
: setupState;
223+
return <Text>{`Setup Required ${message}`}</Text>;
224+
},
225+
};
226+
});
227+
196228
import { App } from './App';
197229

198230
describe('App', () => {
@@ -222,6 +254,8 @@ describe('App', () => {
222254
appendMessage.mockReset();
223255
updateSessionModel.mockReset();
224256
saveConfig.mockReset();
257+
listModels.mockReset();
258+
listModels.mockResolvedValue(['gemma4']);
225259

226260
let counter = 0;
227261
createSession.mockImplementation((model: string) => ({
@@ -409,6 +443,7 @@ describe('App', () => {
409443
it('prints a resume command when the app exits with session messages', async () => {
410444
deleteSessionIfEmpty.mockReturnValue(false);
411445
const { unmount } = render(<App />);
446+
await time.tick();
412447

413448
capturedCallbacks.onMessagesChange?.([
414449
{ role: 'user', content: 'saved message' },
@@ -429,6 +464,7 @@ describe('App', () => {
429464

430465
it('resets the chat session when /clear is issued', async () => {
431466
const { lastFrame, rerender } = render(<App />);
467+
await time.tick();
432468

433469
expect(lastFrame()).toContain('session:session-0');
434470

@@ -453,6 +489,7 @@ describe('App', () => {
453489

454490
it('opens a selected saved session', async () => {
455491
const { lastFrame, rerender } = render(<App />);
492+
await time.tick();
456493
capturedCallbacks.onCommand?.('/session');
457494
rerender(<App />);
458495
await time.tick();
@@ -469,6 +506,7 @@ describe('App', () => {
469506

470507
it('returns to chat when the current session is selected', async () => {
471508
const { lastFrame, rerender } = render(<App />);
509+
await time.tick();
472510
capturedCallbacks.onCommand?.('/session');
473511
rerender(<App />);
474512
await time.tick();
@@ -489,6 +527,55 @@ describe('App', () => {
489527
expect(loadSession).toHaveBeenCalledWith('resumed-session');
490528
});
491529

530+
it('renders setup-needed content when no model is configured', async () => {
531+
const { config } = await import('@/utils');
532+
vi.mocked(config.loadConfig).mockReturnValueOnce({
533+
host: 'http://localhost:11434',
534+
model: undefined,
535+
searxngBaseUrl: undefined,
536+
theme: 'github-dark',
537+
});
538+
539+
const { lastFrame } = render(<App />);
540+
await time.tick();
541+
542+
expect(lastFrame()).toContain('Setup Required');
543+
expect(lastFrame()).toContain('Select or download a model');
544+
expect(lastFrame()).not.toContain('session:');
545+
expect(listModels).not.toHaveBeenCalled();
546+
});
547+
548+
it('renders setup-needed content when no models are installed', async () => {
549+
listModels.mockResolvedValueOnce([]);
550+
551+
const { lastFrame } = render(<App />);
552+
await time.tick();
553+
await time.tick();
554+
555+
expect(lastFrame()).toContain('Setup Required');
556+
expect(lastFrame()).toContain('Download a model');
557+
expect(lastFrame()).not.toContain('session:');
558+
});
559+
560+
it('routes to ModelManager from setup-needed state', async () => {
561+
const { config } = await import('@/utils');
562+
vi.mocked(config.loadConfig).mockReturnValueOnce({
563+
host: 'http://localhost:11434',
564+
model: undefined,
565+
searxngBaseUrl: undefined,
566+
theme: 'github-dark',
567+
});
568+
569+
const { lastFrame, rerender } = render(<App />);
570+
await time.tick();
571+
572+
capturedCallbacks.onCommand?.('/model');
573+
rerender(<App />);
574+
await time.tick();
575+
576+
expect(lastFrame()).toContain('ModelManager');
577+
});
578+
492579
it('creates a new session from SessionManager', async () => {
493580
const { lastFrame, rerender } = render(<App />);
494581
capturedCallbacks.onCommand?.('/session');
@@ -534,6 +621,7 @@ describe('App', () => {
534621

535622
it('persists newly committed messages and skips turn_aborted markers', async () => {
536623
render(<App />);
624+
await time.tick();
537625
appendMessage.mockClear();
538626

539627
capturedCallbacks.onMessagesChange?.([
@@ -560,13 +648,16 @@ describe('App', () => {
560648
});
561649

562650
it('does not append when transcript length does not grow', async () => {
563-
render(<App />);
651+
const { rerender } = render(<App />);
652+
await time.tick();
564653

565654
capturedCallbacks.onMessagesChange?.([{ role: 'user', content: 'saved' }]);
655+
rerender(<App />);
566656
await time.tick();
567657
appendMessage.mockClear();
568658

569659
capturedCallbacks.onMessagesChange?.([{ role: 'user', content: 'saved' }]);
660+
rerender(<App />);
570661
await time.tick();
571662

572663
expect(appendMessage).not.toHaveBeenCalled();
@@ -612,6 +703,7 @@ describe('App', () => {
612703

613704
it('updates footer mode when Chat changes execution mode', async () => {
614705
const { lastFrame, rerender } = render(<App />);
706+
await time.tick();
615707

616708
expect(lastFrame()).toContain('Mode: Safe');
617709

src/components/App/App.tsx

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Box } from 'ink';
2-
import { useCallback, useState } from 'react';
2+
import { useCallback, useEffect, useState } from 'react';
33

44
import { Chat } from '@/components/Chat';
55
import { Footer } from '@/components/Footer';
@@ -10,10 +10,11 @@ import { SessionManager } from '@/components/SessionManager';
1010
import { ThemeSettings } from '@/components/ThemeSettings';
1111
import { MODE, THEME } from '@/constants';
1212
import type { Config, Mode } from '@/types';
13-
import { config, session } from '@/utils';
13+
import { config, ollama, session } from '@/utils';
1414

1515
import { SCREEN } from './constants';
1616
import { useScreenRouter, useSessionManager, useThemeSettings } from './hooks';
17+
import { ReadinessCheck, ReadinessState } from './ReadinessCheck';
1718

1819
interface Props {
1920
sessionId?: string;
@@ -23,6 +24,12 @@ export function App({ sessionId }: Props) {
2324
const [appConfig, setConfig] = useState(() => config.loadConfig());
2425
const [mode, setMode] = useState<Mode>(MODE.SAFE);
2526
const [isHeaderLoaded, setIsHeaderLoaded] = useState(false);
27+
const [setupState, setSetupState] = useState<ReadinessState>(() =>
28+
appConfig.model ? ReadinessState.Ready : ReadinessState.MissingModelConfig,
29+
);
30+
const [setupErrorMessage, setSetupErrorMessage] = useState<string | null>(
31+
null,
32+
);
2633

2734
const { currentScreen, setScreen, handleClose, handleCommand } =
2835
useScreenRouter();
@@ -36,10 +43,66 @@ export function App({ sessionId }: Props) {
3643
handleMessagesChange,
3744
} = useSessionManager({
3845
sessionId,
39-
model: appConfig.model,
46+
model: appConfig.model ?? '',
4047
commandColor: THEME.getTheme(appConfig.theme).colors.command,
4148
});
4249

50+
useEffect(() => {
51+
let isMounted = true;
52+
53+
async function refreshSetupState() {
54+
if (!appConfig.model) {
55+
// v8 ignore next
56+
if (isMounted) {
57+
setSetupErrorMessage(null);
58+
setSetupState(ReadinessState.MissingModelConfig);
59+
}
60+
return;
61+
}
62+
63+
if (currentScreen !== SCREEN.CHAT) {
64+
return;
65+
}
66+
67+
// v8 ignore next
68+
if (isMounted) {
69+
setSetupErrorMessage(null);
70+
setSetupState(ReadinessState.Checking);
71+
}
72+
73+
try {
74+
const installedModels = await ollama.listModels();
75+
if (!isMounted) {
76+
return;
77+
}
78+
79+
setSetupState(
80+
installedModels.length > 0
81+
? ReadinessState.Ready
82+
: ReadinessState.NoInstalledModels,
83+
);
84+
} catch (error) {
85+
// v8 ignore start
86+
if (!isMounted) {
87+
return;
88+
}
89+
90+
setSetupErrorMessage(
91+
error instanceof Error ? error.message : String(error),
92+
);
93+
94+
setSetupState(ReadinessState.ModelLoadError);
95+
// v8 ignore stop
96+
}
97+
}
98+
99+
void refreshSetupState();
100+
101+
return () => {
102+
isMounted = false;
103+
};
104+
}, [appConfig.model, currentScreen]);
105+
43106
const handleUpdateConfig = useCallback(
44107
(update: Partial<Config>) => {
45108
setConfig((current) => ({
@@ -97,7 +160,7 @@ export function App({ sessionId }: Props) {
97160
const handleChatCommand = useCallback(
98161
(command: string) => {
99162
handleCommand(command, {
100-
model: appConfig.model,
163+
model: appConfig.model ?? '',
101164
theme: appConfig.theme,
102165
onCreateSession: handleCreateSession,
103166
onSetPreviewThemeId: setPreviewThemeId,
@@ -139,7 +202,7 @@ export function App({ sessionId }: Props) {
139202
case SCREEN.MODEL_MANAGER:
140203
screenContent = (
141204
<ModelManager
142-
currentModel={appConfig.model}
205+
currentModel={appConfig.model ?? ''}
143206
onSelect={handleUpdateConfig}
144207
onClose={handleClose}
145208
theme={activeTheme}
@@ -183,25 +246,33 @@ export function App({ sessionId }: Props) {
183246
break;
184247

185248
case SCREEN.CHAT:
186-
screenContent = (
187-
<Chat
188-
initialMessages={activeSession.messages}
189-
model={appConfig.model}
190-
onCommand={handleChatCommand}
191-
onMessagesChange={handleMessagesChange}
192-
mode={mode}
193-
onModeChange={setMode}
194-
sessionId={activeSession.metadata.id}
195-
theme={activeTheme}
196-
/>
197-
);
249+
screenContent =
250+
setupState === ReadinessState.Ready ? (
251+
<Chat
252+
initialMessages={activeSession.messages}
253+
model={appConfig.model}
254+
onCommand={handleChatCommand}
255+
onMessagesChange={handleMessagesChange}
256+
mode={mode}
257+
onModeChange={setMode}
258+
sessionId={activeSession.metadata.id}
259+
theme={activeTheme}
260+
/>
261+
) : (
262+
<ReadinessCheck
263+
errorMessage={setupErrorMessage}
264+
onCommand={handleChatCommand}
265+
setupState={setupState}
266+
theme={activeTheme}
267+
/>
268+
);
198269
break;
199270
}
200271

201272
return (
202273
<Box flexDirection="column">
203274
<Header
204-
model={appConfig.model}
275+
model={appConfig.model ?? ''}
205276
onLoad={handleHeaderLoad}
206277
theme={activeTheme}
207278
/>
@@ -210,7 +281,7 @@ export function App({ sessionId }: Props) {
210281

211282
<Footer
212283
mode={mode}
213-
model={appConfig.model}
284+
model={appConfig.model ?? ''}
214285
onToggleMode={handleToggleMode}
215286
theme={activeTheme}
216287
/>

0 commit comments

Comments
 (0)